diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt index 134d0f1d..1c2daabe 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/StatsCommand.kt @@ -96,6 +96,7 @@ data class StatsReportDto( private const val MS_PER_SECOND = 1000L private const val SECONDS_PER_MINUTE = 60L private const val MINUTES_PER_HOUR = 60L +private const val PERCENT_MULTIPLIER = 100.0 private fun humanDuration(ms: Long): String { if (ms <= 0L) return "0s" @@ -111,7 +112,7 @@ private fun humanDuration(ms: Long): String { } private fun otherPct(report: StatsReportDto): Double = - (100.0 - report.inferencePct - report.toolPct - report.approvalWaitPct).coerceAtLeast(0.0) + (PERCENT_MULTIPLIER - report.inferencePct - report.toolPct - report.approvalWaitPct).coerceAtLeast(0.0) fun renderStats(report: StatsReportDto): String { val lines = mutableListOf() @@ -162,10 +163,10 @@ fun renderStats(report: StatsReportDto): String { val q = report.quality lines += "Signal quality" lines += " extraction: %d fetched, %d low-quality (%.0f%% clean)".format( - Locale.ROOT, q.sourceFetches, q.lowQualityExtractions, q.extractionQualityRate * 100.0, + Locale.ROOT, q.sourceFetches, q.lowQualityExtractions, q.extractionQualityRate * PERCENT_MULTIPLIER, ) lines += " retrieval: %d kept, %d filtered (%.0f%% precision)".format( - Locale.ROOT, q.retrievedHits, q.droppedHits, q.retrievalPrecision * 100.0, + Locale.ROOT, q.retrievedHits, q.droppedHits, q.retrievalPrecision * PERCENT_MULTIPLIER, ) lines += " brief drift: ${q.briefDriftCount} capability gaps: ${q.capabilityGapCount}" lines += "" diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt index 70bfce46..02fbde30 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -34,6 +34,9 @@ import kotlinx.serialization.json.put private val taskJson = Json { ignoreUnknownKeys = true } +private const val TASK_ID_COLUMN_WIDTH = 14 +private const val TASK_STATUS_COLUMN_WIDTH = 12 + /** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */ @Serializable data class TaskRow( @@ -60,7 +63,9 @@ fun renderTaskList(tasks: List): String { if (tasks.isEmpty()) return "no tasks" return tasks.joinToString("\n") { t -> val claim = t.claimant?.let { " @$it" }.orEmpty() - "${t.id.padEnd(14)} ${t.status.padEnd(12)} ${t.title.orEmpty()}$claim".trimEnd() + val id = t.id.padEnd(TASK_ID_COLUMN_WIDTH) + val status = t.status.padEnd(TASK_STATUS_COLUMN_WIDTH) + "$id $status ${t.title.orEmpty()}$claim".trimEnd() } } @@ -224,8 +229,10 @@ class TaskCreateCommand : TaskHttpCommand("create") { } val raw = resp.bodyAsText() when { - resp.status == HttpStatusCode.Conflict -> - System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}") + resp.status == HttpStatusCode.Conflict -> { + val dupes = renderTaskList(taskJson.decodeFromString(raw)) + System.err.println("Possible duplicate(s) — pass --force to create anyway:\n$dupes") + } jsonOut() -> println(raw) else -> println("created ${taskJson.decodeFromString(raw).id}") } @@ -250,7 +257,11 @@ class TaskCompleteCommand : TaskHttpCommand("complete") { override fun run() = http { client -> val resp = client.post("${baseUrl()}/tasks/$id/complete") - if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + println("completed $id") + } } } diff --git a/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt index 11a11efd..cc671692 100644 --- a/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt +++ b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt @@ -25,7 +25,9 @@ class TaskRenderTest { val lines = out.lines() assertEquals(2, lines.size) assertTrue(lines[0].startsWith("auth-1")) - assertTrue(lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus")) + assertTrue( + lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus"), + ) assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE")) } @@ -38,7 +40,14 @@ class TaskRenderTest { fun `renderTaskDetail includes goal, criteria, links and notes`() { val out = renderTaskDetail(task) assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh")) - for (want in listOf("goal: users stay authenticated", "- rotates", "backend/auth/**", "adr-7 (IMPLEMENTS -> DOC)", "[AGENT] kickoff")) { + val wants = listOf( + "goal: users stay authenticated", + "- rotates", + "backend/auth/**", + "adr-7 (IMPLEMENTS -> DOC)", + "[AGENT] kickoff", + ) + for (want in wants) { assertTrue(out.contains(want), "detail missing: $want") } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 32dbb027..82260854 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -212,7 +212,12 @@ class ServerModule( // Live-only: subscribeAll() replays nothing and ServerModule is never built under // ReplayOrchestrator, so narration never re-fires on restart/replay (invariant #8). - NarrationSubscriber(eventStore = eventStore, routerFacade = routerFacade, scope = moduleScope, maxPerRun = narrationMaxPerRun).start() + NarrationSubscriber( + eventStore = eventStore, + routerFacade = routerFacade, + scope = moduleScope, + maxPerRun = narrationMaxPerRun, + ).start() // Continuous, edge-triggered health watch (observability-spec §4). Live-only like narration: // probes read the environment and record degraded/restored events; replay reads those facts. @@ -449,7 +454,10 @@ class ServerModule( * TOML registry, so the resume route cannot find them there. Rehydrates the artifact * cache first because the plan content lives in it after a restart. */ - suspend fun freestyleResumeGraph(sessionId: SessionId, workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? { + suspend fun freestyleResumeGraph( + sessionId: SessionId, + workflowId: String, + ): com.correx.core.transitions.graph.WorkflowGraph? { if (!workflowId.startsWith("freestyle-")) return null orchestrator.rehydrate(sessionId) return freestyleDriver?.compiledGraph(sessionId) @@ -614,7 +622,11 @@ class ServerModule( return@forEach } val graph = workflowRegistry.find(orchState.workflowId) ?: run { - log.warn("resumeAbandoned: no graph for session={} workflowId={}", sessionId.value, orchState.workflowId) + log.warn( + "resumeAbandoned: no graph for session={} workflowId={}", + sessionId.value, + orchState.workflowId, + ) return@forEach } log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/logging/LoggingEventStore.kt b/apps/server/src/main/kotlin/com/correx/apps/server/logging/LoggingEventStore.kt index 17920fac..b8b69abd 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/logging/LoggingEventStore.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/logging/LoggingEventStore.kt @@ -60,7 +60,8 @@ class LoggingEventStore(private val delegate: EventStore) : EventStore { override fun subscribe(sessionId: SessionId): Flow = delegate.subscribe(sessionId) override fun allEvents(): Sequence = delegate.allEvents() - override fun allSessionIds(): Set = delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) } + override fun allSessionIds(): Set = + delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) } override fun subscribeAll(): Flow = delegate.subscribeAll() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt index 3cd3e50d..79ab3d44 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/memory/RepoMapIndexer.kt @@ -143,14 +143,28 @@ class RepoMapIndexer( // Top-level declarations only — best-effort per language. Bodies/locals are never matched. val SYMBOL_PATTERNS: Map = mapOf( - "kt" to Regex("""(?m)^\s*(?:public |internal |open |abstract |sealed |data )*(?:class|interface|object|fun)\s+(?[A-Za-z_][A-Za-z0-9_]*)"""), - "java" to Regex("""(?m)^\s*(?:public |private |protected |final |abstract |static )*(?:class|interface|enum|record)\s+(?[A-Za-z_][A-Za-z0-9_]*)"""), + "kt" to Regex( + """(?m)^\s*(?:public |internal |open |abstract |sealed |data )*""" + + """(?:class|interface|object|fun)\s+(?[A-Za-z_][A-Za-z0-9_]*)""", + ), + "java" to Regex( + """(?m)^\s*(?:public |private |protected |final |abstract |static )*""" + + """(?:class|interface|enum|record)\s+(?[A-Za-z_][A-Za-z0-9_]*)""", + ), "py" to Regex("""(?m)^(?:def|class)\s+(?[A-Za-z_][A-Za-z0-9_]*)"""), "go" to Regex("""(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)(?[A-Za-z_][A-Za-z0-9_]*)"""), - "js" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+(?[A-Za-z_$][A-Za-z0-9_$]*)"""), - "ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?[A-Za-z_$][A-Za-z0-9_$]*)"""), + "js" to Regex( + """(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+""" + + """(?[A-Za-z_$][A-Za-z0-9_$]*)""", + ), + "ts" to Regex( + """(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+""" + + """(?[A-Za-z_$][A-Za-z0-9_$]*)""", + ), // GDScript: column-0 declarations only, so indented inner-class members never match. - "gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?[A-Za-z_][A-Za-z0-9_]*)"""), + "gd" to Regex( + """(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?[A-Za-z_][A-Za-z0-9_]*)""", + ), // C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative // (no method capture — return-type heuristics there are noisy and risk false positives). "cs" to Regex( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt b/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt index 9fecf431..336cd90f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/narration/NarrationSubscriber.kt @@ -116,11 +116,13 @@ class NarrationSubscriber( closeLane(sid) } is WorkflowFailedEvent -> { + val instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. " + + "Explain the failure to the user." enqueue( sid, NarrationTrigger( kind = "workflow_failed", - instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. Explain the failure to the user.", + instruction = instruction, stageId = p.stageId.value, ), ) @@ -139,21 +141,27 @@ class NarrationSubscriber( ), ) } - is ExecutionPlanRejectedEvent -> enqueue( - sid, - NarrationTrigger( - kind = "plan_rejected", - instruction = "The execution plan was rejected (${p.source}): ${p.reason}. Explain to the user what happened and what they can do next.", - ), - ) + is ExecutionPlanRejectedEvent -> { + val instruction = "The execution plan was rejected (${p.source}): ${p.reason}. " + + "Explain to the user what happened and what they can do next." + enqueue( + sid, + NarrationTrigger( + kind = "plan_rejected", + instruction = instruction, + ), + ) + } is OrchestrationPausedEvent -> { // Approval pauses are narrated from ApprovalRequestedEvent above. if (!p.reason.contains("APPROVAL", ignoreCase = true)) { + val instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. " + + "Inform the user." enqueue( sid, NarrationTrigger( kind = "paused", - instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.", + instruction = instruction, stageId = p.stageId.value, ), pauseKey = SESSION_PAUSE_KEY, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/replay/ReplayInspectionService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/replay/ReplayInspectionService.kt index 9dbc4d84..f6e62855 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/replay/ReplayInspectionService.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/replay/ReplayInspectionService.kt @@ -63,20 +63,49 @@ class ReplayInspectionService(private val eventStore: EventStore) { private fun toTimelineEntry(sequence: Long, payload: EventPayload, type: String): TimelineEntry = when (payload) { - is WorkflowStartedEvent -> TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}") - is StageCompletedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}") - is StageFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value} — ${payload.reason}") + is WorkflowStartedEvent -> + TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}") + is StageCompletedEvent -> + TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}") + is StageFailedEvent -> TimelineEntry( + sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value} — ${payload.reason}", + ) is ToolExecutionCompletedEvent -> TimelineEntry(sequence, type, null, "Tool executed: ${payload.toolName}") - is ToolExecutionFailedEvent -> TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName} — ${payload.reason}") - is ToolExecutionRejectedEvent -> TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName} — ${payload.reason}") - is ApprovalRequestedEvent -> TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}") - is ApprovalDecisionResolvedEvent -> TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}") - is OrchestrationPausedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration paused at ${payload.stageId.value}: ${payload.reason}") - is OrchestrationResumedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}") - is RetryAttemptedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at ${payload.stageId.value}: ${payload.failureReason}") - is TransitionExecutedEvent -> TimelineEntry(sequence, type, payload.from.value, "Transition ${payload.from.value} → ${payload.to.value}") - is WorkflowCompletedEvent -> TimelineEntry(sequence, type, payload.terminalStageId.value, "Workflow completed after ${payload.totalStages} stages") - is WorkflowFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}") + is ToolExecutionFailedEvent -> + TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName} — ${payload.reason}") + is ToolExecutionRejectedEvent -> + TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName} — ${payload.reason}") + is ApprovalRequestedEvent -> + TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}") + is ApprovalDecisionResolvedEvent -> + TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}") + is OrchestrationPausedEvent -> TimelineEntry( + sequence, + type, + payload.stageId.value, + "Orchestration paused at ${payload.stageId.value}: ${payload.reason}", + ) + is OrchestrationResumedEvent -> TimelineEntry( + sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}", + ) + is RetryAttemptedEvent -> TimelineEntry( + sequence, + type, + payload.stageId.value, + "Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at " + + "${payload.stageId.value}: ${payload.failureReason}", + ) + is TransitionExecutedEvent -> TimelineEntry( + sequence, type, payload.from.value, "Transition ${payload.from.value} → ${payload.to.value}", + ) + is WorkflowCompletedEvent -> TimelineEntry( + sequence, + type, + payload.terminalStageId.value, + "Workflow completed after ${payload.totalStages} stages", + ) + is WorkflowFailedEvent -> + TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}") else -> TimelineEntry(sequence, type, null, type) } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index d3f048a6..79a9c576 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -566,7 +566,8 @@ class GlobalStreamHandler(private val module: ServerModule) { suspend fun requireToolName(scopeLabel: String): String? = msg.toolName?.takeIf { it.isNotBlank() } ?: run { - sendFrame(errorResponse("CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval")) + val errMsg = "CreateGrant: $scopeLabel scope requires toolName to prevent blanket approval" + sendFrame(errorResponse(errMsg)) null } @@ -683,7 +684,9 @@ class GlobalStreamHandler(private val module: ServerModule) { // Bind the per-repo project profile so router triage can cite conventions/commands. // Parity with launchSessionRun — chat sessions previously never bound a profile. runCatching { module.bindProjectProfile(sessionId) } - .onFailure { log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message) } + .onFailure { + log.warn("project profile binding failed for chat session={}: {}", sessionId.value, it.message) + } withSessionContext(sessionId) { runCatching { diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt index 9167e972..8fcf6483 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/SessionStreamHandler.kt @@ -102,7 +102,12 @@ class SessionStreamHandler(private val module: ServerModule) { ), ) }.onFailure { - log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it) + log.error( + "routerFacade.onUserInput failed for session={}: {}", + msg.sessionId.value, + it.message, + it, + ) val error = ServerMessage.ProtocolError("Router error: ${it.message}") session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error))) } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt index eb04f5b5..531beac1 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt @@ -325,7 +325,9 @@ class DomainEventMapperTest { ) val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) assertEquals( - ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2, sequence = event.sequence, sessionSequence = 0L), + ServerMessage.ToolStarted( + sessionId, "file_write", Tier.T2, sequence = event.sequence, sessionSequence = 0L, + ), result, ) } @@ -436,7 +438,8 @@ class DomainEventMapperTest { projectId = null, ), ) - val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired + val result = + domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired assertEquals(requestId, result.requestId) assertEquals(Tier.T3, result.tier) assertNull(result.toolName) @@ -470,11 +473,15 @@ class DomainEventMapperTest { projectId = null, ), ) - val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired + val result = + domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired assertEquals(requestId, result.requestId) assertEquals("HIGH", result.riskSummary.level) assertEquals("PROMPT_USER", result.riskSummary.recommendedAction) - assertEquals(listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), result.riskSummary.rationale) + assertEquals( + listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), + result.riskSummary.rationale, + ) assertEquals(2, result.riskSummary.factors.size) assertTrue(result.riskSummary.factors[0].contains("Validation errors")) assertTrue(result.riskSummary.factors[1].contains("Repeated failure")) @@ -495,7 +502,8 @@ class DomainEventMapperTest { userSteering = null, ), ) - val result = domainEventToServerMessage(event, noopStore, sessionSequence = 5L) as ServerMessage.ApprovalResolved + val result = + domainEventToServerMessage(event, noopStore, sessionSequence = 5L) as ServerMessage.ApprovalResolved assertEquals(sessionId, result.sessionId) assertEquals(requestId, result.requestId) assertEquals("APPROVED", result.outcome) @@ -519,7 +527,8 @@ class DomainEventMapperTest { userSteering = null, ), ) - val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalResolved + val result = + domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalResolved assertEquals("REJECTED", result.outcome) assertNull(result.reason) } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt index 3cd073cd..72ae9adc 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt @@ -325,8 +325,14 @@ class SessionEventBridgeTest { val snapshot = sent[0] as ServerMessage.SessionSnapshot // WorkflowStartedEvent is filtered out by eventToEntry (maps to null) assertEquals(3, snapshot.recentEvents.size) - assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageStarted", "stage-2"), snapshot.recentEvents[0]) - assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageCompleted", stageId.value), snapshot.recentEvents[1]) + assertEquals( + EventEntryDto(timestamp.toEpochMilliseconds(), "StageStarted", "stage-2"), + snapshot.recentEvents[0], + ) + assertEquals( + EventEntryDto(timestamp.toEpochMilliseconds(), "StageCompleted", stageId.value), + snapshot.recentEvents[1], + ) assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "SessionCompleted", ""), snapshot.recentEvents[2]) } @@ -359,7 +365,13 @@ class SessionEventBridgeTest { fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest { val store = fakeEventStore(allEventsList = emptyList()) val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } + val bridge = SessionEventBridge( + store, + noopArtifactStore, + activeOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { sent.add(it) } bridge.replaySnapshot() assertEquals(1, sent.size) assertEquals(ServerMessage.SnapshotComplete, sent[0]) @@ -370,7 +382,13 @@ class SessionEventBridgeTest { val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L)) val store = fakeEventStore(allEventsList = events) val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } + val bridge = SessionEventBridge( + store, + noopArtifactStore, + activeOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { sent.add(it) } bridge.replaySnapshot() assertEquals(ServerMessage.SnapshotComplete, sent.last()) } @@ -385,7 +403,13 @@ class SessionEventBridgeTest { override suspend fun lastGlobalSequence(): Long = 5L } val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } + val bridge = SessionEventBridge( + store, + noopArtifactStore, + activeOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { sent.add(it) } bridge.replaySnapshot() val snapshot = sent[0] as ServerMessage.SessionSnapshot assertEquals(5L, snapshot.lastSequence) @@ -399,7 +423,13 @@ class SessionEventBridgeTest { ) val store = fakeEventStore(allEventsList = events) val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } + val bridge = SessionEventBridge( + store, + noopArtifactStore, + activeOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { sent.add(it) } bridge.replaySnapshot() val snapshot = sent[0] as ServerMessage.SessionSnapshot assertEquals(3L, snapshot.lastSessionSequence) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt index 2a39a346..43f99e02 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt @@ -257,7 +257,10 @@ class FreestyleDriverTest { assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint") val lint = payloads.filterIsInstance().single() - assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}") + assertTrue( + lint.hardFailures.any { it.code == "unproduced_need" }, + "expected unproduced_need: ${lint.hardFailures}", + ) val rejected = payloads.filterIsInstance().single() assertEquals("lint", rejected.source) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/lifecycle/ModelLifecycleLiveTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/lifecycle/ModelLifecycleLiveTest.kt index 61f177eb..7ac25aee 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/lifecycle/ModelLifecycleLiveTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/lifecycle/ModelLifecycleLiveTest.kt @@ -175,7 +175,8 @@ class ModelLifecycleLiveTest { val raw = frame.readText() when (val msg = decodeServerMessage(raw)) { is ServerMessage.ModelChanged -> modelChanged = msg - is ServerMessage.ProtocolError -> error("Unexpected protocol error during swap: ${msg.message}") + is ServerMessage.ProtocolError -> + error("Unexpected protocol error during swap: ${msg.message}") else -> Unit } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceTest.kt index 6b12555c..ae6d473b 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/memory/ProjectMemoryServiceTest.kt @@ -29,7 +29,11 @@ private class OnesEmbedder(override val dimension: Int = 8) : Embedder { class ProjectMemoryServiceTest { - private fun store(sessionId: SessionId, payload: com.correx.core.events.events.EventPayload, es: InMemoryEventStore) = + private fun store( + sessionId: SessionId, + payload: com.correx.core.events.events.EventPayload, + es: InMemoryEventStore, + ) = runBlocking { es.append( NewEvent( @@ -58,7 +62,11 @@ class ProjectMemoryServiceTest { val sessionA = SessionId("A") store(sessionA, SteeringNoteAddedEvent(sessionA, "use jwt for auth"), eventStore) - store(sessionA, TransitionExecutedEvent(sessionA, StageId("plan"), StageId("impl"), TransitionId("t1")), eventStore) + store( + sessionA, + TransitionExecutedEvent(sessionA, StageId("plan"), StageId("impl"), TransitionId("t1")), + eventStore, + ) service(config, eventStore, l3).persist(sessionA, "/repo") @@ -67,7 +75,10 @@ class ProjectMemoryServiceTest { assertTrue(seeded.any { it.contains("use jwt for auth") }, "expected prior decision retrieved") val note = eventStore.read(sessionB).mapNotNull { it.payload as? SteeringNoteAddedEvent }.firstOrNull() - assertTrue(note != null && note.content.contains("Project memory"), "expected seeded steering note in session B") + assertTrue( + note != null && note.content.contains("Project memory"), + "expected seeded steering note in session B", + ) } @Test diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt index a82cd428..14b15222 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/narration/NarrationSubscriberTest.kt @@ -109,7 +109,9 @@ class NarrationSubscriberTest { liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) - liveFlow.emit(storedEvent(StageFailedEvent(sessionId, StageId("b"), TransitionId("t2"), reason = "x"), seq = 3L)) + liveFlow.emit( + storedEvent(StageFailedEvent(sessionId, StageId("b"), TransitionId("t2"), reason = "x"), seq = 3L), + ) liveFlow.emit(storedEvent(WorkflowCompletedEvent(sessionId, StageId("c"), totalStages = 2), seq = 4L)) withTimeout(2_000L) { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index d342afa3..cfb46c91 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -282,7 +282,9 @@ class ServerMessageSerializationTest { val jsonStr = ProtocolSerializer.encodeServerMessage(msg) assert(jsonStr.contains("\"type\":\"workflow.proposed\"")) { "expected type=workflow.proposed" } assert(jsonStr.contains("\"workflowId\":\"research\"")) { "expected candidate id" } - assert(jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\"")) { "expected original request" } + assert( + jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\""), + ) { "expected original request" } val decoded = json.decodeFromString(jsonStr) assertEquals(2, decoded.candidates.size) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistryTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistryTest.kt index df5b617f..d50e7b3f 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistryTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistryTest.kt @@ -56,8 +56,10 @@ class FileSystemWorkflowRegistryTest { @Test fun `listAll falls back to workflowId when description absent`() { - dir.resolve("healthcheck.toml").writeText(validToml.replace("description = \"Runs the health check pipeline\"\n", "")) - assertEquals("healthcheck", FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir).listAll().single().description) + val noDescToml = validToml.replace("description = \"Runs the health check pipeline\"\n", "") + dir.resolve("healthcheck.toml").writeText(noDescToml) + val registry = FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir) + assertEquals("healthcheck", registry.listAll().single().description) } @Test diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/replay/ReplayInspectionServiceTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/replay/ReplayInspectionServiceTest.kt index abff67f5..a6980c4e 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/replay/ReplayInspectionServiceTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/replay/ReplayInspectionServiceTest.kt @@ -87,10 +87,12 @@ class ReplayInspectionServiceTest { private class SeededEventStore(private val events: List) : EventStore { override suspend fun append(event: NewEvent): StoredEvent = error("unused") override suspend fun appendAll(events: List): List = error("unused") - override fun read(sessionId: SessionId): List = events.filter { it.metadata.sessionId == sessionId } + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId } override fun readFrom(sessionId: SessionId, fromSequence: Long): List = events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence } - override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence + override fun lastSequence(sessionId: SessionId): Long? = + events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence override fun subscribe(sessionId: SessionId): Flow = emptyFlow() override fun subscribeAll(): Flow = emptyFlow() override suspend fun lastGlobalSequence(): Long = 0L diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/EventInspectRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/EventInspectRoutesTest.kt index 6c8c27a4..1f3ad73b 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/EventInspectRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/EventInspectRoutesTest.kt @@ -86,18 +86,26 @@ class EventInspectRoutesTest { ) private val seedEvents = listOf( - storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"), 1L), - storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"), 2L), + storedEvent( + WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"), + 1L, + ), + storedEvent( + WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"), + 2L, + ), storedEvent(InitialIntentEvent(sessionId = s1, intent = "build the thing"), 3L), ) private class SeededEventStore(private val events: List) : EventStore { override suspend fun append(event: NewEvent): StoredEvent = error("unused") override suspend fun appendAll(events: List): List = error("unused") - override fun read(sessionId: SessionId): List = events.filter { it.metadata.sessionId == sessionId } + override fun read(sessionId: SessionId): List = + events.filter { it.metadata.sessionId == sessionId } override fun readFrom(sessionId: SessionId, fromSequence: Long): List = events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence } - override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence + override fun lastSequence(sessionId: SessionId): Long? = + events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence override fun subscribe(sessionId: SessionId): Flow = emptyFlow() override fun subscribeAll(): Flow = emptyFlow() override suspend fun lastGlobalSequence(): Long = 0L diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/undo/SessionUndoServiceTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/undo/SessionUndoServiceTest.kt index 5d8379aa..5b8053b8 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/undo/SessionUndoServiceTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/undo/SessionUndoServiceTest.kt @@ -180,7 +180,11 @@ class SessionUndoServiceTest { val noWorkspaceEventStore = FakeEventStore(listOf(storedEvent(fileWritten, sessionId, 1L))) val serviceBootOnly = SessionUndoService(noWorkspaceEventStore, storeNoWorkspace, setOf(bootDir)) val summaryBootOnly = serviceBootOnly.undo(sessionId) - assertEquals(1, summaryBootOnly.rejected, "boot-only roots must jail the workspace-dir file when no bound event exists") + assertEquals( + 1, + summaryBootOnly.rejected, + "boot-only roots must jail the workspace-dir file when no bound event exists", + ) } /** diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt index 437f6ce8..6cb7b73f 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt @@ -121,7 +121,13 @@ class GlobalStreamHandlerTest { orchRepo: OrchestrationRepository, ): Pair> { val received = Channel(Channel.UNLIMITED) - val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, noopWorkflowRegistry, noopToolRegistry) { received.send(it) } + val bridge = SessionEventBridge( + store, + noopArtifactStore, + orchRepo, + noopWorkflowRegistry, + noopToolRegistry, + ) { received.send(it) } return bridge to received } @@ -167,7 +173,13 @@ class GlobalStreamHandlerTest { val liveFlow = MutableSharedFlow(extraBufferCapacity = 64) val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L) val received = Channel(Channel.UNLIMITED) - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { + val bridge = SessionEventBridge( + store, + noopArtifactStore, + idleOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { received.send(it) } val mapper = DomainEventMapper(noopArtifactStore) @@ -209,7 +221,13 @@ class GlobalStreamHandlerTest { val liveFlow = MutableSharedFlow(extraBufferCapacity = 16) val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) val received = Channel(Channel.UNLIMITED) - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { + val bridge = SessionEventBridge( + store, + noopArtifactStore, + idleOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { received.send(it) } val mapper = DomainEventMapper(noopArtifactStore) @@ -257,7 +275,13 @@ class GlobalStreamHandlerTest { return 0L } } - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { } + val bridge = SessionEventBridge( + store, + noopArtifactStore, + idleOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { } val mapper = DomainEventMapper(noopArtifactStore) val job = launch { @@ -274,7 +298,13 @@ class GlobalStreamHandlerTest { fun `shutdown - cancel outer scope ends subscription with no leaks`() = runTest { val liveFlow = MutableSharedFlow() val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { } + val bridge = SessionEventBridge( + store, + noopArtifactStore, + idleOrchestrationRepository(), + noopWorkflowRegistry, + noopToolRegistry, + ) { } val mapper = DomainEventMapper(noopArtifactStore) val job = launch { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt index 08c11ab3..6d4643a9 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/ws/WorkspaceHandshakeTest.kt @@ -250,7 +250,8 @@ class WorkspaceHandshakeTest { delay(50) // Phase 2: start a session - send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)))) + val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null) + send(Frame.Text(encode(startMsg))) // Give the server time to process the event emission (before orchestrator runs) delay(200) @@ -298,7 +299,8 @@ class WorkspaceHandshakeTest { delay(50) // StartSession — this closes the Hello window - send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)))) + val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null) + send(Frame.Text(encode(startMsg))) delay(100) // Late Hello with a different path — must be ignored @@ -334,7 +336,8 @@ class WorkspaceHandshakeTest { delay(100) // No Hello — send StartSession directly - send(Frame.Text(encode(ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null)))) + val startMsg = ClientMessage.StartSession(workflowId = "handshake-test-workflow", config = null) + send(Frame.Text(encode(startMsg))) delay(200) } } diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt index baf5afe3..0929652a 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt @@ -19,7 +19,11 @@ package com.correx.core.artifacts.kind object KindContractTable { /** A single assertion template — a kind's requirement before it is bound to a concrete path. */ - private data class Template(val id: String, val evaluator: AssertionEvaluator, val args: Map = emptyMap()) + private data class Template( + val id: String, + val evaluator: AssertionEvaluator, + val args: Map = emptyMap(), + ) private val FLOOR = listOf( Template("file_exists", AssertionEvaluator.FS), diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index b76312a2..e2e03d4a 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -104,7 +104,10 @@ internal object SimpleToml { ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true } (ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) } ch == quoteChar && inQuotes -> { inQuotes = false; current.append(ch) } - ch == ',' && !inQuotes -> { result.add(stripQuotes(current.toString().trim())); current = StringBuilder() } + ch == ',' && !inQuotes -> { + result.add(stripQuotes(current.toString().trim())) + current = StringBuilder() + } else -> current.append(ch) } } @@ -181,6 +184,46 @@ object ProfileLoader { } object ConfigLoader { + private const val DEFAULT_SERVER_PORT = 8080 + private const val DEFAULT_SESSION_LIST_LIMIT = 5 + private const val DEFAULT_EMBEDDER_DIMENSION = 1536 + private const val DEFAULT_L3_DIM = 1536 + private const val DEFAULT_L3_BIT_WIDTH = 4 + private const val DEFAULT_GENERATION_TEMPERATURE = 0.7 + private const val DEFAULT_GENERATION_TOP_P = 0.9 + private const val DEFAULT_GENERATION_MAX_TOKENS = 512 + private const val DEFAULT_NARRATION_TEMPERATURE = 0.7 + private const val DEFAULT_NARRATION_TOP_P = 0.9 + private const val DEFAULT_NARRATION_MAX_TOKENS = 4096 + private const val DEFAULT_NARRATION_MAX_PER_RUN = 100 + private const val DEFAULT_CONVERSATION_KEEP_LAST = 6 + private const val DEFAULT_RETRIEVAL_K = 5 + private const val DEFAULT_TOKEN_BUDGET = 4096 + private const val DEFAULT_MODEL_CONTEXT_SIZE = 8192 + private const val DEFAULT_PROJECT_MEMORY_K = 5 + private const val DEFAULT_PROJECT_MAX_DEPTH = 4 + private const val DEFAULT_PROJECT_INJECT_TOP_K = 30 + private const val DEFAULT_STAGE_TIMEOUT_MS = 180_000L + private const val DEFAULT_JOURNAL_COMPACTION_TOKEN_THRESHOLD = 2_000 + private const val DEFAULT_RESUME_ABANDONED_MAX_AGE_MINUTES = 1_440L + private const val DEFAULT_COMPRESSION_LEVEL = 4 + private const val DEFAULT_MAX_TOOL_ROUNDS = 30 + private const val DEFAULT_READ_LOOP_NUDGE_THRESHOLD = 3 + private const val DEFAULT_REJECTION_LOOP_NUDGE_THRESHOLD = 3 + private const val DEFAULT_MAX_FEEDBACK_ISSUES = 3 + private const val DEFAULT_REPO_MAP_INJECT_TOP_K = 30 + private const val DEFAULT_REPO_MAP_FILES_PER_DIR = 8 + private const val DEFAULT_DOCS_CATALOG_MAX = 20 + private const val DEFAULT_MAX_CLARIFICATION_ROUNDS = 3 + private const val DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE = 0.7 + private const val DEFAULT_REVIEW_BLOCK_RETRY_CAP = 20 + private const val DEFAULT_MAX_REFINEMENT = 3 + private const val DEFAULT_RECOVERY_ROUTE_BUDGET = 2 + private const val DEFAULT_INTENT_ROUTE_BUDGET = 2 + private const val DEFAULT_MODELS_PORT = 10000 + private const val DEFAULT_SAMPLING_TOP_P = 1.0 + private const val DEFAULT_SAMPLING_TEMPERATURE = 0.7 + fun load(): CorrexConfig { val path = configPath() if (!Files.exists(path)) { @@ -302,7 +345,7 @@ object ConfigLoader { } // JSON-style array: ["item1", "item2"] valueStr.startsWith("[") && valueStr.endsWith("]") -> { - parseArray(valueStr, lineNum) + parseArray(valueStr) } // CSV fallback for backward compat (detect by "," and "=" pattern without brackets) valueStr.contains(",") && valueStr.contains("=") && !valueStr.startsWith("\"") -> { @@ -336,7 +379,7 @@ object ConfigLoader { return result } - private fun parseArray(arrayStr: String, lineNum: Int): List { + private fun parseArray(arrayStr: String): List { val result = mutableListOf() val content = arrayStr.substring(1, arrayStr.length - 1).trim() if (content.isEmpty()) return result @@ -430,12 +473,12 @@ object ConfigLoader { val server = ServerConfig( host = asString(serverSection["host"], "localhost"), - port = asInt(serverSection["port"], 8080), + port = asInt(serverSection["port"], DEFAULT_SERVER_PORT), ) val tui = TuiConfig( theme = asString(tuiSection["theme"], "dark"), - sessionListLimit = asInt(tuiSection["session_list_limit"], 5), + sessionListLimit = asInt(tuiSection["session_list_limit"], DEFAULT_SESSION_LIST_LIMIT), ) val cli = CliConfig( @@ -447,7 +490,9 @@ object ConfigLoader { val shellEnabled = when { toolsShellSection.containsKey("enabled") -> asBoolean(toolsShellSection["enabled"], true) toolsSection.containsKey("shell_enabled") -> { - System.err.println("Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead") + System.err.println( + "Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead" + ) asBoolean(toolsSection["shell_enabled"], true) } else -> true @@ -456,7 +501,9 @@ object ConfigLoader { val fileReadEnabled = when { toolsFileReadSection.containsKey("enabled") -> asBoolean(toolsFileReadSection["enabled"], true) toolsSection.containsKey("file_read_enabled") -> { - System.err.println("Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead") + System.err.println( + "Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead" + ) asBoolean(toolsSection["file_read_enabled"], true) } else -> true @@ -465,7 +512,9 @@ object ConfigLoader { val fileWriteEnabled = when { toolsFileWriteSection.containsKey("enabled") -> asBoolean(toolsFileWriteSection["enabled"], true) toolsSection.containsKey("file_write_enabled") -> { - System.err.println("Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead") + System.err.println( + "Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead" + ) asBoolean(toolsSection["file_write_enabled"], true) } else -> true @@ -474,7 +523,9 @@ object ConfigLoader { val fileEditEnabled = when { toolsFileEditSection.containsKey("enabled") -> asBoolean(toolsFileEditSection["enabled"], true) toolsSection.containsKey("file_edit_enabled") -> { - System.err.println("Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead") + System.err.println( + "Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead" + ) asBoolean(toolsSection["file_edit_enabled"], true) } else -> true @@ -491,7 +542,9 @@ object ConfigLoader { val1 is String -> { // Backward compat: check if it's CSV or already a single item if (val1.contains(",")) { - System.err.println("Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead") + System.err.println( + "Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead" + ) val1.split(",").map { it.trim() }.filter { it.isNotEmpty() } } else { listOf(val1) @@ -547,7 +600,7 @@ object ConfigLoader { val embedder = EmbedderConfig( backend = asString(routerEmbedderSection["backend"], "noop"), - dimension = asInt(routerEmbedderSection["dimension"], 1536), + dimension = asInt(routerEmbedderSection["dimension"], DEFAULT_EMBEDDER_DIMENSION), url = asStringOrNull(routerEmbedderSection["url"]), modelId = asStringOrNull(routerEmbedderSection["model_id"]), ) @@ -557,30 +610,30 @@ object ConfigLoader { persistPath = asStringOrNull(routerL3Section["persist_path"]), pythonExecutable = asString(routerL3Section["python_executable"], "python3"), scriptPath = asStringOrNull(routerL3Section["script_path"]), - dim = asInt(routerL3Section["dim"], 1536), - bitWidth = asInt(routerL3Section["bit_width"], 4), + dim = asInt(routerL3Section["dim"], DEFAULT_L3_DIM), + bitWidth = asInt(routerL3Section["bit_width"], DEFAULT_L3_BIT_WIDTH), ) val generation = GenerationSettings( - temperature = asDouble(routerGenerationSection["temperature"], 0.7), - topP = asDouble(routerGenerationSection["top_p"], 0.9), - maxTokens = asInt(routerGenerationSection["max_tokens"], 512), + temperature = asDouble(routerGenerationSection["temperature"], DEFAULT_GENERATION_TEMPERATURE), + topP = asDouble(routerGenerationSection["top_p"], DEFAULT_GENERATION_TOP_P), + maxTokens = asInt(routerGenerationSection["max_tokens"], DEFAULT_GENERATION_MAX_TOKENS), ) val narration = NarrationSettings( - temperature = asDouble(routerNarrationSection["temperature"], 0.7), - topP = asDouble(routerNarrationSection["top_p"], 0.9), - maxTokens = asInt(routerNarrationSection["max_tokens"], 4096), - maxPerRun = asInt(routerNarrationSection["max_per_run"], 100), + temperature = asDouble(routerNarrationSection["temperature"], DEFAULT_NARRATION_TEMPERATURE), + topP = asDouble(routerNarrationSection["top_p"], DEFAULT_NARRATION_TOP_P), + maxTokens = asInt(routerNarrationSection["max_tokens"], DEFAULT_NARRATION_MAX_TOKENS), + maxPerRun = asInt(routerNarrationSection["max_per_run"], DEFAULT_NARRATION_MAX_PER_RUN), modelId = asStringOrNull(routerNarrationSection["model_id"]), ) val router = TalkieConfig( embedder = embedder, l3 = l3, - conversationKeepLast = asInt(routerSection["conversation_keep_last"], 6), - retrievalK = asInt(routerSection["retrieval_k"], 5), - tokenBudget = asInt(routerSection["token_budget"], 4096), + conversationKeepLast = asInt(routerSection["conversation_keep_last"], DEFAULT_CONVERSATION_KEEP_LAST), + retrievalK = asInt(routerSection["retrieval_k"], DEFAULT_RETRIEVAL_K), + tokenBudget = asInt(routerSection["token_budget"], DEFAULT_TOKEN_BUDGET), generation = generation, narration = narration, ) @@ -588,7 +641,7 @@ object ConfigLoader { val models = modelsList.mapNotNull { modelMap -> val id = asString(modelMap["id"]) ?: return@mapNotNull null val modelPath = asString(modelMap["model_path"]) ?: return@mapNotNull null - val contextSize = asInt(modelMap["context_size"], 8192) + val contextSize = asInt(modelMap["context_size"], DEFAULT_MODEL_CONTEXT_SIZE) @Suppress("UNCHECKED_CAST") val params = (modelMap["params"] as? Map) ?.mapValues { it.value.toString() } @@ -614,42 +667,53 @@ object ConfigLoader { val project = ProjectConfig( enabled = asBoolean(projectSection["enabled"], false), root = asString(projectSection["root"], ""), - memoryK = asInt(projectSection["memory_k"], 5), - maxDepth = asInt(projectSection["max_depth"], 4), + memoryK = asInt(projectSection["memory_k"], DEFAULT_PROJECT_MEMORY_K), + maxDepth = asInt(projectSection["max_depth"], DEFAULT_PROJECT_MAX_DEPTH), ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES }, - injectTopK = asInt(projectSection["inject_top_k"], 30), + injectTopK = asInt(projectSection["inject_top_k"], DEFAULT_PROJECT_INJECT_TOP_K), ) val orchestrationSection = sections["orchestration"] ?: emptyMap() val orchestration = OrchestrationKnobs( - stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], 180_000), - journalCompactionTokenThreshold = - asInt(orchestrationSection["journal_compaction_token_threshold"], 2_000), - resumeAbandonedMaxAgeMinutes = - asLong(orchestrationSection["resume_abandoned_max_age_minutes"], 1_440), - compressionLevel = asInt(orchestrationSection["compression_level"], 4), + stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], DEFAULT_STAGE_TIMEOUT_MS), + journalCompactionTokenThreshold = asInt( + orchestrationSection["journal_compaction_token_threshold"], + DEFAULT_JOURNAL_COMPACTION_TOKEN_THRESHOLD, + ), + resumeAbandonedMaxAgeMinutes = asLong( + orchestrationSection["resume_abandoned_max_age_minutes"], + DEFAULT_RESUME_ABANDONED_MAX_AGE_MINUTES, + ), + compressionLevel = asInt(orchestrationSection["compression_level"], DEFAULT_COMPRESSION_LEVEL), tokenPrunerUrl = asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"), - maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], 30), - readLoopNudgeThreshold = asInt(orchestrationSection["read_loop_nudge_threshold"], 3), - rejectionLoopNudgeThreshold = asInt(orchestrationSection["rejection_loop_nudge_threshold"], 3), - maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], 3), - repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], 30), - repoMapFilesPerDir = asInt(orchestrationSection["repo_map_files_per_dir"], 8), - docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], 20), - maxClarificationRounds = asInt(orchestrationSection["max_clarification_rounds"], 3), - reviewBlockMinConfidence = asDouble(orchestrationSection["review_block_min_confidence"], 0.7), - reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], 20), - defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], 3), - recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], 2), - intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], 2), + maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], DEFAULT_MAX_TOOL_ROUNDS), + readLoopNudgeThreshold = + asInt(orchestrationSection["read_loop_nudge_threshold"], DEFAULT_READ_LOOP_NUDGE_THRESHOLD), + rejectionLoopNudgeThreshold = asInt( + orchestrationSection["rejection_loop_nudge_threshold"], + DEFAULT_REJECTION_LOOP_NUDGE_THRESHOLD, + ), + maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], DEFAULT_MAX_FEEDBACK_ISSUES), + repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], DEFAULT_REPO_MAP_INJECT_TOP_K), + repoMapFilesPerDir = + asInt(orchestrationSection["repo_map_files_per_dir"], DEFAULT_REPO_MAP_FILES_PER_DIR), + docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], DEFAULT_DOCS_CATALOG_MAX), + maxClarificationRounds = + asInt(orchestrationSection["max_clarification_rounds"], DEFAULT_MAX_CLARIFICATION_ROUNDS), + reviewBlockMinConfidence = + asDouble(orchestrationSection["review_block_min_confidence"], DEFAULT_REVIEW_BLOCK_MIN_CONFIDENCE), + reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], DEFAULT_REVIEW_BLOCK_RETRY_CAP), + defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], DEFAULT_MAX_REFINEMENT), + recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], DEFAULT_RECOVERY_ROUTE_BUDGET), + intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], DEFAULT_INTENT_ROUTE_BUDGET), ) val modelsSettings = ModelsSettings( defaultModel = asStringOrNull(modelsSection["default_model"]), llamaServerBin = asString(modelsSection["llama_server_bin"], "llama-server"), host = asString(modelsSection["host"], "127.0.0.1"), - port = asInt(modelsSection["port"], 10000), + port = asInt(modelsSection["port"], DEFAULT_MODELS_PORT), ) val artifacts = artifactsList.mapNotNull { artifactMap -> @@ -664,8 +728,8 @@ object ConfigLoader { val samplingSection = sections["sampling"] ?: emptyMap() val sampling = SamplingConfig( - temperature = asDouble(samplingSection["temperature"], 0.7), - topP = asDouble(samplingSection["top_p"], 1.0), + temperature = asDouble(samplingSection["temperature"], DEFAULT_SAMPLING_TEMPERATURE), + topP = asDouble(samplingSection["top_p"], DEFAULT_SAMPLING_TOP_P), topK = samplingSection["top_k"]?.let { asInt(it) }, minP = samplingSection["min_p"]?.let { asDouble(it) }, repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) }, diff --git a/core/config/src/main/kotlin/com/correx/core/config/EditableConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/EditableConfig.kt index fee6b5b9..f2ff7872 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/EditableConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/EditableConfig.kt @@ -184,7 +184,11 @@ object EditableConfig { "orchestration.resume_abandoned_max_age_minutes", ConfigFieldType.LONG, getString = { it.orchestration.resumeAbandonedMaxAgeMinutes.toString() }, withString = { c, v -> - orc(c) { it.copy(resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)) } + orc(c) { + it.copy( + resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v) + ) + } }, ), ) diff --git a/core/config/src/main/kotlin/com/correx/core/config/OperatorProfileWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/OperatorProfileWriter.kt index 206f066f..51f76244 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/OperatorProfileWriter.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/OperatorProfileWriter.kt @@ -17,7 +17,9 @@ object OperatorProfileWriter { } val prefs = profile.preferences - val hasPrefs = prefs.approvalMode.isNotBlank() || prefs.preferredModels.isNotEmpty() || prefs.conventions.isNotEmpty() + val hasPrefs = prefs.approvalMode.isNotBlank() || + prefs.preferredModels.isNotEmpty() || + prefs.conventions.isNotEmpty() if (hasPrefs) { if (b.isNotEmpty()) b.append('\n') b.append("[preferences]\n") diff --git a/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt index 23126f28..e8075ac8 100644 --- a/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt +++ b/core/context/src/main/kotlin/com/correx/core/context/builder/DefaultContextPackBuilder.kt @@ -97,8 +97,11 @@ class DefaultContextPackBuilder( // Stage 1 (FORMAT_COMPRESS): lossless json→dotted-line compaction of structured entries. val formatted = if (policy.enabled(CompressionStage.FORMAT_COMPRESS)) { deduped.map { entry -> - if (classifier.classify(entry) == ContextClass.STRUCTURED) reencode(entry, formatCompressor.compress(entry.content)) - else entry + if (classifier.classify(entry) == ContextClass.STRUCTURED) { + reencode(entry, formatCompressor.compress(entry.content)) + } else { + entry + } } } else deduped diff --git a/core/context/src/test/kotlin/CompressionPipelineStagesTest.kt b/core/context/src/test/kotlin/CompressionPipelineStagesTest.kt index 857e9a6d..61c2fed0 100644 --- a/core/context/src/test/kotlin/CompressionPipelineStagesTest.kt +++ b/core/context/src/test/kotlin/CompressionPipelineStagesTest.kt @@ -142,7 +142,11 @@ class CompressionPipelineStagesTest { // Instruction-doc pruning is retired: curated/procedural docs (project profile, AGENTS.md // how-to) must reach the model verbatim — token-pruning fused them into unparseable soup. val pruner = object : com.correx.core.context.compression.TokenPruner { - override suspend fun prune(content: String, protectedSpans: List, targetRatio: Double): String = "DOC-PRUNED" + override suspend fun prune( + content: String, + protectedSpans: List, + targetRatio: Double, + ): String = "DOC-PRUNED" } val builder = com.correx.core.context.builder.DefaultContextPackBuilder( com.correx.core.context.compression.DefaultContextCompressor(), CompressionPolicy(3), tokenPruner = pruner, @@ -155,8 +159,11 @@ class CompressionPipelineStagesTest { entry("agi", ContextLayer.L0, EntryRole.SYSTEM, bigInstructions).copy(sourceType = "agentInstructions"), ) val flat = builder.build( - com.correx.core.events.types.ContextPackId("p"), com.correx.core.events.types.SessionId("s"), - com.correx.core.events.types.StageId("st"), entries, com.correx.core.context.model.TokenBudget(limit = 4000), + com.correx.core.events.types.ContextPackId("p"), + com.correx.core.events.types.SessionId("s"), + com.correx.core.events.types.StageId("st"), + entries, + com.correx.core.context.model.TokenBudget(limit = 4000), ).layers.values.flatten() assertEquals(bigProfile, flat.first { it.sourceType == "projectProfile" }.content) assertEquals("you are an agent", flat.first { it.sourceType == "systemPrompt" }.content) @@ -171,7 +178,10 @@ class CompressionPipelineStagesTest { assertEquals(ContextClass.STRUCTURED, c.classify(entry("toolLog", ContextLayer.L2, EntryRole.TOOL))) assertEquals(ContextClass.STRUCTURED, c.classify(entry("steeringNote", ContextLayer.L2, EntryRole.SYSTEM))) // Assistant tool-call turns are structured history, never token-pruned (amnesia-loop fix). - assertEquals(ContextClass.STRUCTURED, c.classify(entry("assistantToolCall", ContextLayer.L2, EntryRole.ASSISTANT))) + assertEquals( + ContextClass.STRUCTURED, + c.classify(entry("assistantToolCall", ContextLayer.L2, EntryRole.ASSISTANT)), + ) assertEquals(ContextClass.FREEFORM, c.classify(entry("chat", ContextLayer.L1, EntryRole.USER))) } } diff --git a/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt b/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt index 50574a8b..bd9bc0e8 100644 --- a/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt +++ b/core/events/src/main/kotlin/com/correx/core/approvals/Tier.kt @@ -3,14 +3,16 @@ package com.correx.core.approvals import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Suppress("MagicNumber") @Serializable -enum class Tier(val level: Int) { - @SerialName("T0") T0(0), - @SerialName("T1") T1(1), - @SerialName("T2") T2(2), - @SerialName("T3") T3(3), - @SerialName("T4") T4(4) +enum class Tier { + @SerialName("T0") T0, + @SerialName("T1") T1, + @SerialName("T2") T2, + @SerialName("T3") T3, + @SerialName("T4") T4; + + /** Escalation level, ordered T0 < T1 < … < T4. Derived from declaration order — no magic numbers. */ + val level: Int get() = ordinal } fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ArtifactRepairEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ArtifactRepairEventSerializationTest.kt index 834375b1..bdee8bb9 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/ArtifactRepairEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ArtifactRepairEventSerializationTest.kt @@ -28,6 +28,10 @@ class ArtifactRepairEventSerializationTest { @Test fun `ArtifactRepairFailed round-trips`() { - roundTrip(ArtifactRepairFailedEvent(ArtifactId("a"), "UNSAFE", "Abort", "path escapes", SessionId("s"), StageId("st"))) + roundTrip( + ArtifactRepairFailedEvent( + ArtifactId("a"), "UNSAFE", "Abort", "path escapes", SessionId("s"), StageId("st"), + ), + ) } } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt index aaf3eef6..9f5ab36a 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt @@ -14,7 +14,11 @@ class PlanLintCompletedEventSerializationTest { sessionId = SessionId("sess-1"), candidateId = "freestyle-sess-1", hardFailures = listOf( - PlanLintFinding(code = "unproduced_need", stageId = "implement", detail = "needs 'design', produced by no stage"), + PlanLintFinding( + code = "unproduced_need", + stageId = "implement", + detail = "needs 'design', produced by no stage", + ), PlanLintFinding(code = "trap_state", stageId = "loop", detail = "no path to 'done'"), ), softFindings = listOf( diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt index d271f7d2..fcc251bd 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoKnowledgeRetrievedEventSerializationTest.kt @@ -44,7 +44,11 @@ class RepoKnowledgeRetrievedEventSerializationTest { query = "context builder", hits = listOf( RepoKnowledgeHit(path = "core/context/ContextBuilder.kt", text = "class ContextBuilder", score = 0.95f), - RepoKnowledgeHit(path = "core/context/DefaultContextBuilder.kt", text = "class DefaultContextBuilder", score = 0.88f), + RepoKnowledgeHit( + path = "core/context/DefaultContextBuilder.kt", + text = "class DefaultContextBuilder", + score = 0.88f, + ), ), ) assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(), diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt index a62320ad..e0cb8821 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/RepoMapComputedEventSerializationTest.kt @@ -28,7 +28,8 @@ class RepoMapComputedEventSerializationTest { @Test fun `RepoMapComputedEvent without stateKey field decodes with null`() { - val json = """{"type":"RepoMapComputed","sessionId":"s","repoRoot":"/repo","entries":[],"computedAt":"2026-06-11T00:00:00Z"}""" + val json = """{"type":"RepoMapComputed","sessionId":"s","repoRoot":"/repo",""" + + """"entries":[],"computedAt":"2026-06-11T00:00:00Z"}""" val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) as RepoMapComputedEvent assertNull(decoded.stateKey) } diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ToolCallAssessedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ToolCallAssessedEventSerializationTest.kt index 8e469e1c..368d922b 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/ToolCallAssessedEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ToolCallAssessedEventSerializationTest.kt @@ -23,7 +23,12 @@ class ToolCallAssessedEventSerializationTest { observations = listOf( ToolCallObservation( ruleCode = "PATH_CONTAINMENT", - facts = mapOf("path" to "/tmp/x", "exists" to "false", "inWorkspace" to "false", "privileged" to "false"), + facts = mapOf( + "path" to "/tmp/x", + "exists" to "false", + "inWorkspace" to "false", + "privileged" to "false", + ), ), ), disposition = RiskAction.PROMPT_USER, diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt index 50fdc105..b18185af 100644 --- a/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt +++ b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt @@ -54,7 +54,12 @@ class DecisionJournalRenderer(private val keepLast: Int = 40) { * agents don't act on "Approval APPROVED (tier T2)" or "Advanced x → y", so they're * dropped from the render (still present in [DecisionJournalState.records] for the journal). */ - private val AGENT_RELEVANT_KINDS = - setOf(DecisionKind.INTENT, DecisionKind.STEERING, DecisionKind.PREEMPT, DecisionKind.FAILURE, DecisionKind.RETRY) + private val AGENT_RELEVANT_KINDS = setOf( + DecisionKind.INTENT, + DecisionKind.STEERING, + DecisionKind.PREEMPT, + DecisionKind.FAILURE, + DecisionKind.RETRY, + ) } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluator.kt index 4e1e3483..3c56f719 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluator.kt @@ -81,12 +81,14 @@ class FileSystemContractEvaluator : ContractAssertionEvaluator { private fun scriptDefined(path: Path, exists: Boolean, name: String): ContractAssertionVerdict { val scripts = jsonObj(path, exists)?.get("scripts")?.let { runCatching { it.jsonObject }.getOrNull() } ?: return verdict(false, "no scripts block") - return verdict(scripts.containsKey(name), if (scripts.containsKey(name)) "script '$name' defined" else "missing script '$name'") + val hasScript = scripts.containsKey(name) + return verdict(hasScript, if (hasScript) "script '$name' defined" else "missing script '$name'") } private fun contains(path: Path, exists: Boolean, pattern: String): ContractAssertionVerdict { val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist") - return verdict(text.contains(pattern), if (text.contains(pattern)) "contains '$pattern'" else "missing '$pattern'") + val hasPattern = text.contains(pattern) + return verdict(hasPattern, if (hasPattern) "contains '$pattern'" else "missing '$pattern'") } private fun textMatch(path: Path, exists: Boolean, needle: String, failMsg: String): ContractAssertionVerdict { diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt index 18cf8403..a7b51f2e 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt @@ -158,7 +158,9 @@ class ReplayOrchestrator( session: Session, ): ReplayStepResult { log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}") - return when (val result = executeStage(ctx.sessionId, stageId, ctx.graph, session, ctx.config, effectivesFor(ctx.config))) { + val result = + executeStage(ctx.sessionId, stageId, ctx.graph, session, ctx.config, effectivesFor(ctx.config)) + return when (result) { is StageExecutionResult.Success -> ReplayStepResult.Continue( ctx.copy(stageCount = ctx.stageCount + 1), ) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/replay/ReplayInferenceProvider.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/replay/ReplayInferenceProvider.kt index b84a0188..56c7405b 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/replay/ReplayInferenceProvider.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/replay/ReplayInferenceProvider.kt @@ -20,13 +20,17 @@ class ReplayInferenceProvider( override val id: ProviderId = ProviderId("replay-provider") - @Suppress("MagicNumber") + private companion object { + /** Rough char→token ratio for the replay tokenizer (no real BPE at replay time). */ + const val CHARS_PER_TOKEN = 4 + } + override val tokenizer: Tokenizer = object : Tokenizer { override suspend fun tokenize(text: String): List = - List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars + List(text.chunked(CHARS_PER_TOKEN).size) { i -> Token(i) } override suspend fun countTokens(text: String): Int = - (text.length + 3) / 4 + (text.length + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN } override suspend fun infer(request: InferenceRequest): InferenceResponse { diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ArtifactPolicyDecisionTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ArtifactPolicyDecisionTest.kt index ee7bcf55..c44e563e 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ArtifactPolicyDecisionTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ArtifactPolicyDecisionTest.kt @@ -6,7 +6,8 @@ import org.junit.jupiter.api.Test class ArtifactPolicyDecisionTest { - private fun decideFresh(f: ArtifactFailure) = decide(f, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false) + private fun decideFresh(f: ArtifactFailure) = + decide(f, attemptsSoFar = 0, maxAttempts = Int.MAX_VALUE, hasApprover = false) @Test fun `unsafe always aborts, even with budget and approver`() { diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt index 9f27e28d..238eb610 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/JournalCompactionServiceTest.kt @@ -51,7 +51,8 @@ class JournalCompactionServiceTest { } @Test - fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`(): Unit = runBlocking { + fun `emits JournalCompactedEvent with correct coversThroughSequence and lowSalienceOmittedCount`(): + Unit = runBlocking { val svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = { 100 }) val state = stateWithRecords( makeRecord(1, DecisionKind.INTENT), // HIGH diff --git a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt index d1d61045..6da99dae 100644 --- a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt +++ b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieContextBuilder.kt @@ -108,6 +108,9 @@ class DefaultTalkieContextBuilder( /** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */ private const val MAX_IDEAS_IN_CONTEXT = 10 + + /** Rough chars-per-token heuristic used when no tokenizer is available. */ + private const val CHARS_PER_TOKEN_ESTIMATE = 4 } override suspend fun build( @@ -389,7 +392,7 @@ class DefaultTalkieContextBuilder( } private fun fallbackEstimate(content: String): Int { - return (content.length / 4).coerceAtLeast(1) + return (content.length / CHARS_PER_TOKEN_ESTIMATE).coerceAtLeast(1) } /** diff --git a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt index 16412705..a7a1dcbb 100644 --- a/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt +++ b/core/talkie/src/main/kotlin/com/correx/core/talkie/TalkieFacade.kt @@ -146,7 +146,12 @@ class DefaultTalkieFacade( // Rebuild state so lastRetrievedMemory reflects this turn state = routerRepository.getTalkieState(sessionId) - val contextPack = routerContextBuilder.build(state, config.tokenBudget, workflowSummaryProvider(), sessionProfileProvider(sessionId)) + val contextPack = routerContextBuilder.build( + state, + config.tokenBudget, + workflowSummaryProvider(), + sessionProfileProvider(sessionId), + ) if (contextPack.compressionMetadata.entriesDropped > 0) { val truncNow = Clock.System.now() @@ -195,7 +200,8 @@ class DefaultTalkieFacade( "without producing an answer (likely spent on hidden reasoning). " + "Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again." } else { - "The model returned an empty response (finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again." + "The model returned an empty response " + + "(finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again." } } diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt index 9b809bb4..4e7f10a0 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskContextBundle.kt @@ -33,13 +33,22 @@ data class TaskContextBundle( appendLine("task $id [$status] ${title.orEmpty()}".trimEnd()) goal?.let { appendLine("goal: $it") } if (blocked) appendLine("BLOCKED — waiting on ${blockedBy.size} unfinished dependency(ies):") - appendList("blocked_by", blockedBy) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() } + appendList("blocked_by", blockedBy) { + " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() + } appendList("acceptance_criteria", acceptanceCriteria) { " - $it" } if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}") - appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() } + appendList("dependencies", dependencies) { + " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() + } appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" } - appendList("artifacts", artifacts) { " - ${it.targetId} (${it.type})${it.stage?.let { s -> " @$s" }.orEmpty()}: ${it.excerpt.orEmpty()}".trimEnd() } - appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() } + appendList("artifacts", artifacts) { + val stageSuffix = it.stage?.let { s -> " @$s" }.orEmpty() + " - ${it.targetId} (${it.type})$stageSuffix: ${it.excerpt.orEmpty()}".trimEnd() + } + appendList("sessions", sessions) { + " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() + } appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" } appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" } appendList("notes", notes) { " - [${it.author}] ${it.body}" } diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt index fae08179..4eacdd84 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/DefaultTaskReducerTest.kt @@ -118,7 +118,12 @@ class DefaultTaskReducerTest { val state = reduceAll( created(), TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")), - TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK), + TaskLinkedEvent( + taskId, + targetId = "auth-138", + type = TaskLinkType.DEPENDS_ON, + targetKind = TaskTargetKind.TASK, + ), TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"), ) diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt index 61923c71..a9cbcd2e 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ManifestContainmentRule.kt @@ -43,7 +43,8 @@ class ManifestContainmentRule : ToolCallRule { for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { val candidate = Path.of(raw) - val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate) + val resolvedInput = + if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate) val resolvedReal = input.probe.resolveReal(resolvedInput) val inWorkspace = resolvedReal.startsWith(workspaceReal) val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt index ffebfac7..52a35c7a 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/PathContainmentRule.kt @@ -35,7 +35,8 @@ class PathContainmentRule : ToolCallRule { for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { val candidate = Path.of(raw) - val resolvedInput = if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate) + val resolvedInput = + if (candidate.isAbsolute) candidate else input.workspace.workspaceRoot.resolve(candidate) val resolvedReal = input.probe.resolveReal(resolvedInput) val exists = input.probe.exists(resolvedInput) val privileged = privilegedReal.any { resolvedReal.startsWith(it) } diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt index 43e408cf..77186f20 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt @@ -26,7 +26,13 @@ class ReferenceExistsRuleTest { private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput( - request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_read", mapOf("path" to pathArg)), + request = ToolRequest( + ToolInvocationId("i"), + SessionId("s"), + StageId("st"), + "file_read", + mapOf("path" to pathArg), + ), capabilities = setOf(ToolCapability.FILE_READ), workspace = WorkspacePolicy(workspace, emptyList()), probe = probe, diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt index 2e091c4c..f1e0e3c2 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/StaleWriteRuleTest.kt @@ -27,7 +27,13 @@ class StaleWriteRuleTest { private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() private fun input(onDisk: String, session: SessionContext) = ToolCallAssessmentInput( - request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_edit", mapOf("path" to target)), + request = ToolRequest( + ToolInvocationId("i"), + SessionId("s"), + StageId("st"), + "file_edit", + mapOf("path" to target), + ), capabilities = setOf(ToolCapability.FILE_WRITE), workspace = WorkspacePolicy(workspace, emptyList()), probe = FakeProbe(mapOf(abs(target) to onDisk)), diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt index 63389229..6f78db70 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/WriteScopeRuleTest.kt @@ -23,7 +23,13 @@ class WriteScopeRuleTest { } private fun input(pathArg: String, active: ActiveTask?) = ToolCallAssessmentInput( - request = ToolRequest(ToolInvocationId("i"), SessionId("s"), StageId("st"), "file_write", mapOf("path" to pathArg)), + request = ToolRequest( + ToolInvocationId("i"), + SessionId("s"), + StageId("st"), + "file_write", + mapOf("path" to pathArg), + ), capabilities = setOf(ToolCapability.FILE_WRITE), workspace = WorkspacePolicy(workspace, emptyList()), probe = FakeProbe(), @@ -34,7 +40,10 @@ class WriteScopeRuleTest { @Test fun `no active task means nothing to enforce`() { - assertEquals(RiskAction.PROCEED, rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition) + assertEquals( + RiskAction.PROCEED, + rule.assess(input("/work/project/core/billing/X.kt", active = null)).disposition, + ) } @Test diff --git a/core/validation/src/test/kotlin/com/correx/core/validation/artifact/ArtifactExtractionPipelineTest.kt b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/ArtifactExtractionPipelineTest.kt index 275e2e0c..6bc99400 100644 --- a/core/validation/src/test/kotlin/com/correx/core/validation/artifact/ArtifactExtractionPipelineTest.kt +++ b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/ArtifactExtractionPipelineTest.kt @@ -20,7 +20,10 @@ class ArtifactExtractionPipelineTest { ) private fun resolved(raw: String) = - assertInstanceOf(ArtifactExtractionPipeline.ExtractionResult.Resolved::class.java, pipeline.run(raw, fileWritten)) + assertInstanceOf( + ArtifactExtractionPipeline.ExtractionResult.Resolved::class.java, + pipeline.run(raw, fileWritten), + ) @Test fun `clean json passes through`() { diff --git a/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt index 1b8d490c..2fd80f2f 100644 --- a/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt +++ b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt @@ -45,7 +45,10 @@ class JsonSchemaValidatorTest { @Test fun `unknown property is allowed when additionalProperties is true`() { val open = schema.copy(additionalProperties = true) - val result = JsonSchemaValidator.validate(open, Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}""")) + val result = JsonSchemaValidator.validate( + open, + Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}"""), + ) assertTrue(result.isEmpty()) } diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt index 9e581029..9535ab14 100644 --- a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppEmbedder.kt @@ -17,6 +17,7 @@ import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory private const val DEFAULT_REQUEST_TIMEOUT_MS = 60_000L +private const val ERROR_BODY_PREVIEW_LENGTH = 200 private fun defaultHttpClient(): HttpClient = HttpClient(CIO) { install(ContentNegotiation) { @@ -125,9 +126,10 @@ class LlamaCppEmbedder( return nestedResult } + val preview = responseBody.take(ERROR_BODY_PREVIEW_LENGTH) throw IllegalStateException( "Unexpected embedding response shape from $url. " + - "Response body (first 200 chars): ${responseBody.take(200)}" + "Response body (first $ERROR_BODY_PREVIEW_LENGTH chars): $preview" ) } } diff --git a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt index 1dbeb9ae..d4a498d3 100644 --- a/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt +++ b/infrastructure/inference/llama_cpp/src/main/kotlin/com/correx/infrastructure/inference/llama/cpp/LlamaCppInferenceProvider.kt @@ -260,7 +260,8 @@ class LlamaCppInferenceProvider( when { line.startsWith("id = ") -> id = line.removePrefix("id = ").trim() line.startsWith("function.name = ") -> name = line.removePrefix("function.name = ").trim() - line.startsWith("function.arguments = ") -> arguments = line.removePrefix("function.arguments = ").trim() + line.startsWith("function.arguments = ") -> + arguments = line.removePrefix("function.arguments = ").trim() } } return if (name != null && arguments != null) { diff --git a/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt index faf92ab0..3181c242 100644 --- a/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt +++ b/infrastructure/persistence/src/test/kotlin/com/correx/infrastructure/persistence/SqliteEventStoreConcurrencyTest.kt @@ -41,7 +41,11 @@ class SqliteEventStoreConcurrencyTest { assertEquals(n, events.size, "Expected exactly $n events but got ${events.size}") val sessionSeqs = events.map { it.sessionSequence }.sorted() - assertEquals((1L..n.toLong()).toList(), sessionSeqs, "Session sequences must be exactly 1..$n with no gaps or duplicates") + assertEquals( + (1L..n.toLong()).toList(), + sessionSeqs, + "Session sequences must be exactly 1..$n with no gaps or duplicates", + ) val globalSeqs = events.map { it.sequence } assertEquals(globalSeqs.size, globalSeqs.toSet().size, "Global sequence values must all be unique") diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index 309aac21..2e22bd50 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -84,12 +84,17 @@ object InfrastructureModule { private val defaultArtifactsRoot: String = "${System.getProperty("user.home")}/.config/correx/artifacts" + private const val LLAMA_CODING_SCORE = 0.7 + private const val LLAMA_REASONING_SCORE = 0.6 + private const val LLAMA_SUMMARIZATION_SCORE = 0.8 + private const val LLAMA_TOOL_CALLING_SCORE = 0.5 + private val DEFAULT_LLAMA_CAPABILITIES: Set = setOf( CapabilityScore(ModelCapability.General, 1.0), - CapabilityScore(ModelCapability.Coding, 0.7), - CapabilityScore(ModelCapability.Reasoning, 0.6), - CapabilityScore(ModelCapability.Summarization, 0.8), - CapabilityScore(ModelCapability.ToolCalling, 0.5), + CapabilityScore(ModelCapability.Coding, LLAMA_CODING_SCORE), + CapabilityScore(ModelCapability.Reasoning, LLAMA_REASONING_SCORE), + CapabilityScore(ModelCapability.Summarization, LLAMA_SUMMARIZATION_SCORE), + CapabilityScore(ModelCapability.ToolCalling, LLAMA_TOOL_CALLING_SCORE), ) fun createEventStore(artifactStore: ArtifactStore, dbPath: String = defaultDbPath): EventStore { @@ -326,6 +331,17 @@ object InfrastructureModule { val repository = DefaultTalkieRepository(replayer) val ideaReader = IdeaReader(eventStore) val contextBuilder = DefaultTalkieContextBuilder(config, tokenizer, ideaProvider = { ideaReader.activeIdeas() }) - return DefaultTalkieFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider) + return DefaultTalkieFacade( + repository, + contextBuilder, + inferenceRouter, + eventStore, + config, + null, + embedder, + l3MemoryStore, + workflowSummaryProvider, + sessionProfileProvider, + ) } } diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt index e7e78310..2deca9d4 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/FileWriteTool.kt @@ -39,9 +39,10 @@ class FileWriteTool( private val normalizedAllowedPaths: Set = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() override val name: String = "file_write" - override val description: String = "Write content to a file at the specified relative path (creates or overwrites). " + - "Writing to a nested path auto-creates any missing parent directories — there is no separate " + - "mkdir step. Do not write shell commands into a file's content." + override val description: String = + "Write content to a file at the specified relative path (creates or overwrites). " + + "Writing to a nested path auto-creates any missing parent directories — there is no separate " + + "mkdir step. Do not write shell commands into a file's content." override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt index 8811e162..03548afa 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt @@ -179,7 +179,9 @@ class ListDirTool( private fun render(root: Path, entries: List, truncated: Boolean, ignoredCount: Int): String { val notes = buildList { if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory") - if (ignoredCount > 0) add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore") + if (ignoredCount > 0) { + add("$ignoredCount ${if (ignoredCount == 1) "entry" else "entries"} hidden by .gitignore") + } } return buildString { appendLine("$root") @@ -203,14 +205,14 @@ class ListDirTool( .filter { it != targetName && isSimilarName(it, targetName) } .toList() .sortedWith(compareByDescending { commonPrefixLen(it, targetName) }.thenBy { it }) - .take(3) + .take(MAX_SIMILAR_SUGGESTIONS) } } catch (_: Exception) { emptyList() } } private fun isSimilarName(a: String, b: String): Boolean { val prefix = commonPrefixLen(a, b) - return prefix >= 2 || levenshtein(a, b) <= 3 + return prefix >= SIMILAR_NAME_MIN_COMMON_PREFIX || levenshtein(a, b) <= SIMILAR_NAME_MAX_LEVENSHTEIN } private fun commonPrefixLen(a: String, b: String): Int { @@ -238,6 +240,9 @@ class ListDirTool( private companion object { const val MAX_ENTRIES = 400 + const val MAX_SIMILAR_SUGGESTIONS = 3 + const val SIMILAR_NAME_MIN_COMMON_PREFIX = 2 + const val SIMILAR_NAME_MAX_LEVENSHTEIN = 3 } } diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchTools.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchTools.kt index 626bc690..03b3f70a 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchTools.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchTools.kt @@ -35,6 +35,7 @@ private const val MAX_RESULTS = 200 // symbol search wants, and reading it line-by-line stalls the walk. ponytail: fixed byte cap; make it // a param if a real use case needs to grep large generated files. private const val GREP_MAX_FILE_BYTES = 2_000_000L +private const val GREP_LINE_PREVIEW_LENGTH = 200 /** * `.gitignore`-aware path search by glob pattern — the affordance a model should reach for to answer @@ -58,7 +59,10 @@ class GlobTool( putJsonObject("properties") { putJsonObject("pattern") { put("type", "string") - put("description", "Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.") + put( + "description", + "Glob pattern, e.g. '**/*.kt' or 'src/**/use*.ts'. Matched against paths relative to 'path'.", + ) } putJsonObject("path") { put("type", "string") @@ -80,13 +84,27 @@ class GlobTool( return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false) } val pattern = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() } - ?: return@withContext ToolResult.Failure(request.invocationId, "glob requires a non-empty 'pattern'", recoverable = true) + ?: return@withContext ToolResult.Failure( + request.invocationId, + "glob requires a non-empty 'pattern'", + recoverable = true, + ) val base = resolveSearchPath(request, workingDir) if (!Files.isDirectory(base)) { - return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true) + return@withContext ToolResult.Failure( + request.invocationId, + "Not a directory: ${request.parameters["path"] ?: "."}", + recoverable = true, + ) } val matcher = runCatching { FileSystems.getDefault().getPathMatcher("glob:$pattern") } - .getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid glob pattern: ${it.message}", recoverable = true) } + .getOrElse { + return@withContext ToolResult.Failure( + request.invocationId, + "Invalid glob pattern: ${it.message}", + recoverable = true, + ) + } val hits = ArrayList() var truncated = false @@ -147,16 +165,36 @@ class GrepTool( return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false) } val patternStr = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() } - ?: return@withContext ToolResult.Failure(request.invocationId, "grep requires a non-empty 'pattern'", recoverable = true) + ?: return@withContext ToolResult.Failure( + request.invocationId, + "grep requires a non-empty 'pattern'", + recoverable = true, + ) val regex = runCatching { Regex(patternStr) } - .getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid regex: ${it.message}", recoverable = true) } + .getOrElse { + return@withContext ToolResult.Failure( + request.invocationId, + "Invalid regex: ${it.message}", + recoverable = true, + ) + } val base = resolveSearchPath(request, workingDir) if (!Files.isDirectory(base)) { - return@withContext ToolResult.Failure(request.invocationId, "Not a directory: ${request.parameters["path"] ?: "."}", recoverable = true) + return@withContext ToolResult.Failure( + request.invocationId, + "Not a directory: ${request.parameters["path"] ?: "."}", + recoverable = true, + ) } val fileFilter = (request.parameters["glob"] as? String)?.takeIf { it.isNotBlank() }?.let { runCatching { FileSystems.getDefault().getPathMatcher("glob:$it") } - .getOrElse { e -> return@withContext ToolResult.Failure(request.invocationId, "Invalid glob: ${e.message}", recoverable = true) } + .getOrElse { e -> + return@withContext ToolResult.Failure( + request.invocationId, + "Invalid glob: ${e.message}", + recoverable = true, + ) + } } val hits = ArrayList() @@ -169,7 +207,7 @@ class GrepTool( if (text != null && !text.contains('\u0000')) { text.lineSequence().forEachIndexed { idx, line -> if (!truncated && regex.containsMatchIn(line)) { - hits += "$rel:${idx + 1}: ${line.trim().take(200)}" + hits += "$rel:${idx + 1}: ${line.trim().take(GREP_LINE_PREVIEW_LENGTH)}" if (hits.size >= MAX_RESULTS) truncated = true } } diff --git a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchToolsTest.kt b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchToolsTest.kt index 64f51ec0..50523c4e 100644 --- a/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchToolsTest.kt +++ b/infrastructure/tools/filesystem/src/test/kotlin/com/correx/infrastructure/tools/filesystem/WorkspaceSearchToolsTest.kt @@ -69,7 +69,8 @@ class WorkspaceSearchToolsTest { fun `grep respects an optional file glob and prunes gitignore`(): Unit = runBlocking { val root = fixture() val tool = GrepTool(allowedPaths = setOf(root), workingDir = root) - val out = (tool.execute(request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts"))) as ToolResult.Success).output + val req = request("grep", mapOf("pattern" to "useAuth", "glob" to "**/*.ts")) + val out = (tool.execute(req) as ToolResult.Success).output assertTrue(out.contains("src/hooks/useAuth.ts:"), out) assertTrue(out.contains("src/main.ts:"), out) assertFalse(out.contains("node_modules"), out) // gitignored diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt index e5691dcb..5b772b7a 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskCreateTool.kt @@ -119,10 +119,19 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor { } return null } - val duplicates = service.findDuplicates(ProjectId(request.stringParam("project")!!), request.stringParam("title")!!) + val duplicates = service.findDuplicates( + ProjectId(request.stringParam("project")!!), + request.stringParam("title")!!, + ) return duplicates.takeIf { it.isNotEmpty() }?.let { - val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } - fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") + val listed = it.joinToString(", ") { d -> + "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" + } + fail( + request, + "Possible duplicate(s): $listed. Reuse one (task_context/task_update), " + + "or force=true with force_reason.", + ) } } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt index 0dae9869..16121180 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskDecomposeTool.kt @@ -75,7 +75,10 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { putJsonObject("depends_on") { put("type", "array") putJsonObject("items") { put("type", "string") } - put("description", "refs (or 0-based indices) of tasks in THIS batch that must finish first.") + put( + "description", + "refs (or 0-based indices) of tasks in THIS batch that must finish first.", + ) } } put("required", buildJsonArray { add(JsonPrimitive("title")); add(JsonPrimitive("goal")) }) @@ -123,7 +126,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { dedupFailure(request, project, specs, parent)?.let { return it } val pid = ProjectId(project) - val parentTask = parent?.let { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) } + val parentTask = parent?.let { + service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) + } val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) } wireLinks(adjacency, children, parentTask) recordProvenance(request, listOfNotNull(parentTask) + children) @@ -140,7 +145,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { /** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */ private suspend fun wireLinks(adjacency: List>, children: List, parentTask: Task?) { adjacency.forEachIndexed { i, deps -> - deps.forEach { j -> service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) } + deps.forEach { j -> + service.link(children[i].taskId, children[j].taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK) + } } if (parentTask != null) { children.forEach { child -> @@ -156,15 +163,26 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { if (!request.boolParam("force")) return val reason = request.stringParam("force_reason") ?: return created.forEach { - service.addNote(it.taskId, TaskNoteAuthor.AGENT, "[force] created via decompose despite the duplicate guard: $reason") + service.addNote( + it.taskId, + TaskNoteAuthor.AGENT, + "[force] created via decompose despite the duplicate guard: $reason", + ) } } /** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */ - private fun dedupFailure(request: ToolRequest, project: String, specs: List, parent: TaskSpec?): ToolResult.Failure? { + private fun dedupFailure( + request: ToolRequest, + project: String, + specs: List, + parent: TaskSpec?, + ): ToolResult.Failure? { val titles = (listOfNotNull(parent?.title) + specs.map { it.title }) val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys - if (within.isNotEmpty()) return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.") + if (within.isNotEmpty()) { + return fail(request, "Duplicate titles within this batch: ${within.joinToString(", ")}.") + } if (request.boolParam("force")) { return if (request.stringParam("force_reason") == null) { fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.") @@ -175,8 +193,14 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { val pid = ProjectId(project) val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value } return clashes.takeIf { it.isNotEmpty() }?.let { - val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } - fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") + val listed = it.joinToString(", ") { d -> + "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" + } + fail( + request, + "Possible duplicate(s): $listed. Reuse one (task_context/task_update), " + + "or force=true with force_reason.", + ) } } @@ -194,7 +218,8 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { val readyLine = if (readyIds.isEmpty()) { "Nothing is ready yet — check the dependency graph." } else { - "Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; the rest unblock as their dependencies complete." + "Ready to work now: ${readyIds.joinToString(", ")}. Claim one to start; " + + "the rest unblock as their dependencies complete." } return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" + lines.joinToString("\n") + "\n" + readyLine @@ -230,7 +255,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { (obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" } private fun strList(obj: JsonObject, key: String): List = - (obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList() + (obj[key] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } + ?: emptyList() /** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */ private fun elementOf(request: ToolRequest, name: String) = @@ -253,7 +280,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor { val deps = ArrayList() for (d in s.deps) { val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices } - ?: return Edges.Err("task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.") + ?: return Edges.Err( + "task #$i ('${s.title}') depends_on '$d', which is not a ref or index in this batch.", + ) if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.") deps.add(j) } diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt index 66e61887..f17f7d58 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt @@ -35,7 +35,10 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor { put("type", "string") put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.") } - putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") } + putJsonObject("limit") { + put("type", "integer") + put("description", "Max results (default $DEFAULT_LIMIT).") + } } } override val tier: Tier = Tier.T1 @@ -50,7 +53,9 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor { val output = if (ready.isEmpty()) { "no ready tasks" } else { - ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() } + ready.joinToString("\n") { + "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() + } } return ToolResult.Success( invocationId = request.invocationId, diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt index dc287d4e..85199e34 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt @@ -35,12 +35,18 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor { override val parametersSchema: JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { - putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") } + putJsonObject("query") { + put("type", "string") + put("description", "Free-text query; all terms must match.") + } putJsonObject("project") { put("type", "string") put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.") } - putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") } + putJsonObject("limit") { + put("type", "integer") + put("description", "Max results (default $DEFAULT_LIMIT).") + } } put("required", buildJsonArray { add(JsonPrimitive("query")) }) } @@ -61,7 +67,9 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor { val output = if (hits.isEmpty()) { "no tasks match \"$query\"" } else { - hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() } + hits.joinToString("\n") { + "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() + } } return ToolResult.Success( invocationId = request.invocationId, diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 3710fef9..25284cde 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -69,23 +69,35 @@ class TaskUpdateTool( } putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") } putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") } - putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") } + putJsonObject("link_target") { + put("type", "string") + put("description", "Id to link to (task/artifact/session).") + } putJsonObject("link_type") { put("type", "string") put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.") } putJsonObject("link_kind") { put("type", "string") - put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.") + put( + "description", + "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.", + ) } putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") } putJsonObject("force") { put("type", "boolean") - put("description", "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.") + put( + "description", + "Override a gate (claim over unmet blockers; complete with no in-scope writes). Default false.", + ) } putJsonObject("force_reason") { put("type", "string") - put("description", "Required when force=true: why the override is justified. Recorded as a note on the task.") + put( + "description", + "Required when force=true: why the override is justified. Recorded as a note on the task.", + ) } } put("required", buildJsonArray { add(JsonPrimitive("id")) }) @@ -159,7 +171,10 @@ class TaskUpdateTool( val unmet = service.blockers(taskId) if (unmet.isEmpty()) return null val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } - return fail(request, "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.") + return fail( + request, + "Cannot claim ${taskId.value} — unmet blockers: $listed. Complete them, or force=true with force_reason.", + ) } /** diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt index 5757d329..05ed71ed 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt @@ -88,7 +88,7 @@ class WebFetchTool( val contentType = response.headers[HttpHeaders.ContentType] val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() when { - response.status.value in 300..399 -> + response.status.value in HTTP_REDIRECT_MIN..HTTP_REDIRECT_MAX -> fail( invocationId, "URL redirected (HTTP ${response.status.value}) to " + @@ -159,5 +159,7 @@ class WebFetchTool( companion object { const val DEFAULT_MAX_BYTES: Long = 10_000_000 // 10 MB — a 200MB "page" is an attack or a mistake private const val READ_CHUNK = 8_192 + private const val HTTP_REDIRECT_MIN = 300 + private const val HTTP_REDIRECT_MAX = 399 } } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 857b51ea..d4edf14c 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -94,7 +94,10 @@ class TaskToolsTest { // affected_paths survived the JSON parse (not dropped like listParam would on the flattened arg). assertEquals(listOf("apps/web/**"), service.getTask(TaskId("webui-2"))!!.state.affectedPaths) // scaffold is the only ready task; the dependents and the epic are blocked. - assertEquals(listOf("webui-2"), service.ready(com.correx.core.events.types.ProjectId("webui")).map { it.taskId.value }) + assertEquals( + listOf("webui-2"), + service.ready(com.correx.core.events.types.ProjectId("webui")).map { it.taskId.value }, + ) assertEquals(listOf("webui-2"), service.blockers(TaskId("webui-3")).map { it.taskId.value }) assertEquals(3, service.blockers(TaskId("webui-1")).size) // child implements the epic; epic depends on the child. @@ -122,12 +125,14 @@ class TaskToolsTest { @Test fun `task_decompose rejects a dependency cycle`() = runBlocking { + val cyclicTasks = """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},""" + + """{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]""" val result = decompose.execute( request( "task_decompose", mapOf( "project" to "webui", - "tasks" to """[{"ref":"a","title":"A","goal":"g","depends_on":["b"]},{"ref":"b","title":"B","goal":"g","depends_on":["a"]}]""", + "tasks" to cyclicTasks, ), ), ) @@ -168,7 +173,8 @@ class TaskToolsTest { assertTrue(decompose.execute(request("task_decompose", dup)) is ToolResult.Failure) assertTrue(decompose.execute(request("task_decompose", dup + ("force" to true))) is ToolResult.Failure) - val forced = decompose.execute(request("task_decompose", dup + ("force" to true) + ("force_reason" to "intentional rebuild"))) + val forcedArgs = dup + ("force" to true) + ("force_reason" to "intentional rebuild") + val forced = decompose.execute(request("task_decompose", forcedArgs)) assertTrue(forced is ToolResult.Success) val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",") assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") }) @@ -254,8 +260,15 @@ class TaskToolsTest { @Test fun `task_search ranks matches across projects and reports misses`() = runBlocking { - create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed"))) - create.execute(request("task_create", mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys"))) + create.execute( + request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")), + ) + create.execute( + request( + "task_create", + mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys"), + ), + ) val hit = search.execute(request("task_search", mapOf("query" to "jwt"))) assertTrue(hit is ToolResult.Success) @@ -288,13 +301,18 @@ class TaskToolsTest { fun `task_create blocks a duplicate title and force needs a recorded reason`() = runBlocking { create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "g"))) - val dup = create.execute(request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g"))) + val dup = create.execute( + request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g")), + ) assertTrue(dup is ToolResult.Failure) assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true)) // force without a reason is rejected. val noReason = create.execute( - request("task_create", mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true)), + request( + "task_create", + mapOf("project" to "auth", "title" to "jwt refresh", "goal" to "g", "force" to true), + ), ) assertTrue(noReason is ToolResult.Failure) assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason")) @@ -332,7 +350,9 @@ class TaskToolsTest { assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated // force without a reason is rejected. - val noReason = update.execute(request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true))) + val noReason = update.execute( + request("task_update", mapOf("id" to "auth-2", "action" to "claim", "force" to true)), + ) assertTrue(noReason is ToolResult.Failure) assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason")) assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) @@ -341,12 +361,21 @@ class TaskToolsTest { val forced = update.execute( request( "task_update", - mapOf("id" to "auth-2", "action" to "claim", "force" to true, "force_reason" to "prep work, auth-1 lands soon"), + mapOf( + "id" to "auth-2", + "action" to "claim", + "force" to true, + "force_reason" to "prep work, auth-1 lands soon", + ), ), ) assertTrue((forced as ToolResult.Success).output.contains("WARNING")) assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status) - assertTrue(service.getTask(TaskId("auth-2"))!!.state.notes.any { it.body.contains("[force]") && it.body.contains("prep work") }) + assertTrue( + service.getTask(TaskId("auth-2"))!!.state.notes.any { + it.body.contains("[force]") && it.body.contains("prep work") + }, + ) } @Test diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt index 921a5d81..9d61860e 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt @@ -41,7 +41,9 @@ object PlanLinter { fun lint(graph: WorkflowGraph, seeds: Set = seedArtifacts): PlanLintResult = PlanLintResult( - hardFailures = unproducedNeeds(graph, seeds) + trapStates(graph) + unreferencedPromptArtifacts(graph, seeds), + hardFailures = unproducedNeeds(graph, seeds) + + trapStates(graph) + + unreferencedPromptArtifacts(graph, seeds), softFindings = stageCount(graph) + fanOut(graph) + emptyBriefs(graph) + duplicateBriefs(graph), ) diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt index df556f20..a39436a9 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt @@ -147,7 +147,10 @@ class PlanLinterTest { start = "implement", ) val result = PlanLinter.lint(g) - assertTrue(result.hardFailures.none { it.code == "trap_state" }, "loop with exit must not trap: ${result.hardFailures}") + assertTrue( + result.hardFailures.none { it.code == "trap_state" }, + "loop with exit must not trap: ${result.hardFailures}", + ) assertTrue(result.passed, "hard=${result.hardFailures}") } diff --git a/tool-result-schema.md b/tool-result-schema.md new file mode 100644 index 00000000..21735a33 --- /dev/null +++ b/tool-result-schema.md @@ -0,0 +1,254 @@ +# Tool Result Schema — What the Model Sees + +Documenting how opencode formats tool results into the LLM context. +Source: github.com/anomalyco/opencode (branch `dev`). + +--- + +## Internal Types (TypeScript `@opencode-ai/schema`) + +### `ToolResultValue` — the 4-variant result envelope + +Defined in `packages/llm/src/schema/messages.ts`: + +```typescript +ToolResultValue = Union([ + { type: "json", value: unknown }, // structured data, no text content + { type: "text", value: unknown }, // single text item + { type: "error", value: unknown }, // tool error + { type: "content", value: ToolContent[] }, // multiple items (text + files) +]) +``` + +### `ToolContent` — inner content items + +Defined in `packages/schema/src/llm.ts`: + +```typescript +ToolContent = Union([ + { type: "text", text: string }, + { type: "file", uri: string, mime: string, name?: string }, +]) +``` + +### `ToolResultPart` — message part injected into conversation + +```typescript +ToolResultPart = Struct({ + type: "tool-result", + id: string, // matches ToolCallPart.id + name: string, + result: ToolResultValue, + providerExecuted?: boolean, + cache?: CacheHint, + metadata?: Record, + providerMetadata?: ProviderMetadata, +}) +``` + +### Conversion: `ToolOutput` → `ToolResultValue` + +In `ToolOutput.toResultValue()` (`packages/llm/src/schema/messages.ts`): + +``` +content.length === 0 → {type: "json", value: output.structured} +content.length === 1 && is text → {type: "text", value: text} +otherwise → {type: "content", value: output.content} +``` + +--- + +## Per-Tool Formatting + +Each tool defines `toModelOutput()` which returns `{type: "text" | "file"}[]`. + +### Bash / Shell + +File: `packages/core/src/tool/bash.ts` + +``` + + +Command exited with code 0. +``` + +If truncated by `ToolOutputStore.bound()` (default 2000 lines / 50KB): + +``` +... output truncated; full content saved to /path/to/tool-output/tool_abc123 ... + +[last lines here] + +Command exited with code 0. +``` + +If timed out: + +``` +Command exceeded timeout of 120000 ms. Retry with a larger timeout. +``` + +### Read (text — opencode tool) + +File: `packages/opencode/src/tool/read.ts` + +``` +/path/to/file.ts +file + +1: line one +2: line two +3: line three +... + +(Showing lines 1-50 of 200. Use offset=51 to continue.) +``` + +If a context file (marked as `context` in config): + +``` + +... + +``` + +### Read (image — core tool) + +File: `packages/core/src/tool/read.ts` + +``` +Image read successfully +``` + +Plus a file attachment (base64 data URI with mime type). + +### Write + +File: `packages/core/src/tool/write.ts` + +``` +Wrote file successfully: /path/to/file +``` +or +``` +Created file successfully: /path/to/file +``` + +### Edit + +File: `packages/core/src/tool/edit.ts` + +``` +Edited file successfully: /path/to/file +Replacements: 1 +```diff +-old line ++new line + +``` + +Preview shows first 6 lines of old (prefixed `-`) and new (prefixed `+`). + +### Glob + +File: `packages/core/src/tool/glob.ts` + +``` +src/file1.ts +src/file2.ts +src/subdir/file3.ts +``` +or `No files found` + +### Grep + +File: `packages/core/src/tool/grep.ts` + +``` +Found 3 matches +src/file.ts: + Line 10: matching text + Line 20: more matching text + +src/other.ts: + Line 5: another match +``` + +### WebFetch + +File: `packages/core/src/tool/webfetch.ts` + +Raw fetched content as plain text. + +### Question + +File: `packages/core/src/tool/question.ts` + +``` +User has answered your questions: "What color?"="Blue". You can now continue with the user's answers in mind. +``` + +--- + +## Truncation (ToolOutputStore.bound) + +File: `packages/core/src/tool-output-store.ts` + +Default limits: **2000 lines / 50 KB**. + +When exceeded: +- Full output saved to `tool-output/tool_` in managed storage +- In-memory text replaced with head/tail preview (~25 lines / ~25KB each) + marker + +The marker text between head and tail: + +``` +... output truncated; full content saved to /path/to/tool-output/tool_abc123 ... +``` + +--- + +## Protocol Lowering (Wire Format) + +### OpenAI Chat (`packages/llm/src/protocols/openai-chat.ts`) + +Always a flat string: +```json +{"role": "tool", "tool_call_id": "...", "content": ""} +``` + +Images are appended as a separate `{role: "user", content: [{type: "image_url", ...}]}` message. + +### Anthropic Messages (`packages/llm/src/protocols/anthropic-messages.ts`) + +``` +tool_result.content = + string // for text/error/json variants + [{type: "text", text: ...}, // for "content" variant + {type: "image", source: {type: "base64", media_type, data}}] +``` + +### Gemini / Vertex (`packages/llm/src/protocols/google-gemini.ts`) + +``` +tool_result → {role: "user", parts: [ + {text: ""}, + {inlineData: {mimeType, data: base64}} +]} +``` + +--- + +## Summary Table + +| Tool | What Model Sees | +|------|----------------| +| **bash** | stdout/stderr + `Command exited with code N` | +| **read** (text) | XML-tagged `` with line numbers | +| **read** (image) | "Image read successfully" + base64 file attachment | +| **write** | "Wrote/Created file successfully: /path" | +| **edit** | "Edited file successfully" + diff block | +| **glob** | Newline-separated paths (or "No files found") | +| **grep** | "Found N matches" + formatted line listing | +| **webfetch** | Raw fetched content | +| **question** | "User has answered your questions: ..." |