refactor(detekt): extract magic-number constants, wrap long lines, drop legit suppressions

Mechanical detekt cleanup across apps/core/infrastructure (behavior-preserving):
- MagicNumber 62->10: named constants + removed 'legit' MagicNumber suppressions
  (Tier level from ordinal, ReplayInferenceProvider CHARS_PER_TOKEN, ConfigLoader
  DEFAULT_* consts, capability scores, HTTP ranges, column widths, etc.)
- MaxLineLength cut to ~0 in production modules (line wraps, no logic change)
- Dropped one dead parameter (ConfigLoader.parseArray lineNum)

Structural findings (ReturnCount/LongMethod/Complexity/LargeClass) left for a
deliberate decomposition pass. Full build + detekt gate green.
This commit is contained in:
2026-07-12 23:12:28 +04:00
parent 135a34eb2f
commit aee2e67c66
70 changed files with 1034 additions and 236 deletions
@@ -96,6 +96,7 @@ data class StatsReportDto(
private const val MS_PER_SECOND = 1000L private const val MS_PER_SECOND = 1000L
private const val SECONDS_PER_MINUTE = 60L private const val SECONDS_PER_MINUTE = 60L
private const val MINUTES_PER_HOUR = 60L private const val MINUTES_PER_HOUR = 60L
private const val PERCENT_MULTIPLIER = 100.0
private fun humanDuration(ms: Long): String { private fun humanDuration(ms: Long): String {
if (ms <= 0L) return "0s" if (ms <= 0L) return "0s"
@@ -111,7 +112,7 @@ private fun humanDuration(ms: Long): String {
} }
private fun otherPct(report: StatsReportDto): Double = 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 { fun renderStats(report: StatsReportDto): String {
val lines = mutableListOf<String>() val lines = mutableListOf<String>()
@@ -162,10 +163,10 @@ fun renderStats(report: StatsReportDto): String {
val q = report.quality val q = report.quality
lines += "Signal quality" lines += "Signal quality"
lines += " extraction: %d fetched, %d low-quality (%.0f%% clean)".format( 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( 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 += " brief drift: ${q.briefDriftCount} capability gaps: ${q.capabilityGapCount}"
lines += "" lines += ""
@@ -34,6 +34,9 @@ import kotlinx.serialization.json.put
private val taskJson = Json { ignoreUnknownKeys = true } 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. */ /** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */
@Serializable @Serializable
data class TaskRow( data class TaskRow(
@@ -60,7 +63,9 @@ fun renderTaskList(tasks: List<TaskRow>): String {
if (tasks.isEmpty()) return "no tasks" if (tasks.isEmpty()) return "no tasks"
return tasks.joinToString("\n") { t -> return tasks.joinToString("\n") { t ->
val claim = t.claimant?.let { " @$it" }.orEmpty() 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() val raw = resp.bodyAsText()
when { when {
resp.status == HttpStatusCode.Conflict -> resp.status == HttpStatusCode.Conflict -> {
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n${renderTaskList(taskJson.decodeFromString(raw))}") val dupes = renderTaskList(taskJson.decodeFromString(raw))
System.err.println("Possible duplicate(s) — pass --force to create anyway:\n$dupes")
}
jsonOut() -> println(raw) jsonOut() -> println(raw)
else -> println("created ${taskJson.decodeFromString<TaskRow>(raw).id}") else -> println("created ${taskJson.decodeFromString<TaskRow>(raw).id}")
} }
@@ -250,7 +257,11 @@ class TaskCompleteCommand : TaskHttpCommand("complete") {
override fun run() = http { client -> override fun run() = http { client ->
val resp = client.post("${baseUrl()}/tasks/$id/complete") val resp = client.post("${baseUrl()}/tasks/$id/complete")
if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id") if (resp.status == HttpStatusCode.NotFound) {
System.err.println("No such task: $id")
} else {
println("completed $id")
}
} }
} }
@@ -25,7 +25,9 @@ class TaskRenderTest {
val lines = out.lines() val lines = out.lines()
assertEquals(2, lines.size) assertEquals(2, lines.size)
assertTrue(lines[0].startsWith("auth-1")) 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")) assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE"))
} }
@@ -38,7 +40,14 @@ class TaskRenderTest {
fun `renderTaskDetail includes goal, criteria, links and notes`() { fun `renderTaskDetail includes goal, criteria, links and notes`() {
val out = renderTaskDetail(task) val out = renderTaskDetail(task)
assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh")) 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") assertTrue(out.contains(want), "detail missing: $want")
} }
} }
@@ -212,7 +212,12 @@ class ServerModule(
// Live-only: subscribeAll() replays nothing and ServerModule is never built under // Live-only: subscribeAll() replays nothing and ServerModule is never built under
// ReplayOrchestrator, so narration never re-fires on restart/replay (invariant #8). // 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: // 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. // 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 * 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. * 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 if (!workflowId.startsWith("freestyle-")) return null
orchestrator.rehydrate(sessionId) orchestrator.rehydrate(sessionId)
return freestyleDriver?.compiledGraph(sessionId) return freestyleDriver?.compiledGraph(sessionId)
@@ -614,7 +622,11 @@ class ServerModule(
return@forEach return@forEach
} }
val graph = workflowRegistry.find(orchState.workflowId) ?: run { 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 return@forEach
} }
log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId) log.info("resumeAbandoned: launching session={} workflow={}", sessionId.value, orchState.workflowId)
@@ -60,7 +60,8 @@ class LoggingEventStore(private val delegate: EventStore) : EventStore {
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = delegate.subscribe(sessionId) override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = delegate.subscribe(sessionId)
override fun allEvents(): Sequence<StoredEvent> = delegate.allEvents() override fun allEvents(): Sequence<StoredEvent> = delegate.allEvents()
override fun allSessionIds(): Set<SessionId> = delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) } override fun allSessionIds(): Set<SessionId> =
delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) }
override fun subscribeAll(): Flow<StoredEvent> = delegate.subscribeAll() override fun subscribeAll(): Flow<StoredEvent> = delegate.subscribeAll()
@@ -143,14 +143,28 @@ class RepoMapIndexer(
// Top-level declarations only — best-effort per language. Bodies/locals are never matched. // Top-level declarations only — best-effort per language. Bodies/locals are never matched.
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf( val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
"kt" to Regex("""(?m)^\s*(?:public |internal |open |abstract |sealed |data )*(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""), "kt" to Regex(
"java" to Regex("""(?m)^\s*(?:public |private |protected |final |abstract |static )*(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""), """(?m)^\s*(?:public |internal |open |abstract |sealed |data )*""" +
"""(?:class|interface|object|fun)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
"java" to Regex(
"""(?m)^\s*(?:public |private |protected |final |abstract |static )*""" +
"""(?:class|interface|enum|record)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
"py" to Regex("""(?m)^(?:def|class)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""), "py" to Regex("""(?m)^(?:def|class)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"go" to Regex("""(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)(?<name>[A-Za-z_][A-Za-z0-9_]*)"""), "go" to Regex("""(?m)^\s*(?:func\s+(?:\([^)]*\)\s*)?|type\s+)(?<name>[A-Za-z_][A-Za-z0-9_]*)"""),
"js" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""), "js" to Regex(
"ts" to Regex("""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+(?<name>[A-Za-z_$][A-Za-z0-9_$]*)"""), """(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class)\s+""" +
"""(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
),
"ts" to Regex(
"""(?m)^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+""" +
"""(?<name>[A-Za-z_$][A-Za-z0-9_$]*)""",
),
// GDScript: column-0 declarations only, so indented inner-class members never match. // GDScript: column-0 declarations only, so indented inner-class members never match.
"gd" to Regex("""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)"""), "gd" to Regex(
"""(?m)^(?:static\s+)?(?:func|class_name|class|signal|enum)\s+(?<name>[A-Za-z_][A-Za-z0-9_]*)""",
),
// C# / Godot Mono: top-level type declarations with leading modifiers, kept conservative // 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). // (no method capture — return-type heuristics there are noisy and risk false positives).
"cs" to Regex( "cs" to Regex(
@@ -116,11 +116,13 @@ class NarrationSubscriber(
closeLane(sid) closeLane(sid)
} }
is WorkflowFailedEvent -> { is WorkflowFailedEvent -> {
val instruction = "The workflow failed in stage ${p.stageId.value}: ${p.reason}. " +
"Explain the failure to the user."
enqueue( enqueue(
sid, sid,
NarrationTrigger( NarrationTrigger(
kind = "workflow_failed", 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, stageId = p.stageId.value,
), ),
) )
@@ -139,21 +141,27 @@ class NarrationSubscriber(
), ),
) )
} }
is ExecutionPlanRejectedEvent -> enqueue( is ExecutionPlanRejectedEvent -> {
val instruction = "The execution plan was rejected (${p.source}): ${p.reason}. " +
"Explain to the user what happened and what they can do next."
enqueue(
sid, sid,
NarrationTrigger( NarrationTrigger(
kind = "plan_rejected", kind = "plan_rejected",
instruction = "The execution plan was rejected (${p.source}): ${p.reason}. Explain to the user what happened and what they can do next.", instruction = instruction,
), ),
) )
}
is OrchestrationPausedEvent -> { is OrchestrationPausedEvent -> {
// Approval pauses are narrated from ApprovalRequestedEvent above. // Approval pauses are narrated from ApprovalRequestedEvent above.
if (!p.reason.contains("APPROVAL", ignoreCase = true)) { if (!p.reason.contains("APPROVAL", ignoreCase = true)) {
val instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. " +
"Inform the user."
enqueue( enqueue(
sid, sid,
NarrationTrigger( NarrationTrigger(
kind = "paused", kind = "paused",
instruction = "Orchestration paused in stage ${p.stageId.value}: ${p.reason}. Inform the user.", instruction = instruction,
stageId = p.stageId.value, stageId = p.stageId.value,
), ),
pauseKey = SESSION_PAUSE_KEY, pauseKey = SESSION_PAUSE_KEY,
@@ -63,20 +63,49 @@ class ReplayInspectionService(private val eventStore: EventStore) {
private fun toTimelineEntry(sequence: Long, payload: EventPayload, type: String): TimelineEntry = private fun toTimelineEntry(sequence: Long, payload: EventPayload, type: String): TimelineEntry =
when (payload) { when (payload) {
is WorkflowStartedEvent -> TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}") is WorkflowStartedEvent ->
is StageCompletedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage completed: ${payload.stageId.value}") TimelineEntry(sequence, type, payload.startStageId.value, "Workflow started: ${payload.workflowId}")
is StageFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Stage failed: ${payload.stageId.value}${payload.reason}") 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 ToolExecutionCompletedEvent -> TimelineEntry(sequence, type, null, "Tool executed: ${payload.toolName}")
is ToolExecutionFailedEvent -> TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName}${payload.reason}") is ToolExecutionFailedEvent ->
is ToolExecutionRejectedEvent -> TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName}${payload.reason}") TimelineEntry(sequence, type, null, "Tool failed: ${payload.toolName}${payload.reason}")
is ApprovalRequestedEvent -> TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}") is ToolExecutionRejectedEvent ->
is ApprovalDecisionResolvedEvent -> TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}") TimelineEntry(sequence, type, null, "Tool rejected: ${payload.toolName}${payload.reason}")
is OrchestrationPausedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration paused at ${payload.stageId.value}: ${payload.reason}") is ApprovalRequestedEvent ->
is OrchestrationResumedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Orchestration resumed at ${payload.stageId.value}") TimelineEntry(sequence, type, payload.stageId?.value, "Approval requested: tier ${payload.tier}")
is RetryAttemptedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Retry attempt ${payload.attemptNumber}/${payload.maxAttempts} at ${payload.stageId.value}: ${payload.failureReason}") is ApprovalDecisionResolvedEvent ->
is TransitionExecutedEvent -> TimelineEntry(sequence, type, payload.from.value, "Transition ${payload.from.value} ${payload.to.value}") TimelineEntry(sequence, type, null, "Approval resolved: ${payload.outcome}")
is WorkflowCompletedEvent -> TimelineEntry(sequence, type, payload.terminalStageId.value, "Workflow completed after ${payload.totalStages} stages") is OrchestrationPausedEvent -> TimelineEntry(
is WorkflowFailedEvent -> TimelineEntry(sequence, type, payload.stageId.value, "Workflow failed: ${payload.reason}") 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) else -> TimelineEntry(sequence, type, null, type)
} }
@@ -566,7 +566,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
suspend fun requireToolName(scopeLabel: String): String? = suspend fun requireToolName(scopeLabel: String): String? =
msg.toolName?.takeIf { it.isNotBlank() } msg.toolName?.takeIf { it.isNotBlank() }
?: run { ?: 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 null
} }
@@ -683,7 +684,9 @@ class GlobalStreamHandler(private val module: ServerModule) {
// Bind the per-repo project profile so router triage can cite conventions/commands. // Bind the per-repo project profile so router triage can cite conventions/commands.
// Parity with launchSessionRun — chat sessions previously never bound a profile. // Parity with launchSessionRun — chat sessions previously never bound a profile.
runCatching { module.bindProjectProfile(sessionId) } 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) { withSessionContext(sessionId) {
runCatching { runCatching {
@@ -102,7 +102,12 @@ class SessionStreamHandler(private val module: ServerModule) {
), ),
) )
}.onFailure { }.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}") val error = ServerMessage.ProtocolError("Router error: ${it.message}")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error))) session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
} }
@@ -325,7 +325,9 @@ class DomainEventMapperTest {
) )
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals( 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, result,
) )
} }
@@ -436,7 +438,8 @@ class DomainEventMapperTest {
projectId = null, 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(requestId, result.requestId)
assertEquals(Tier.T3, result.tier) assertEquals(Tier.T3, result.tier)
assertNull(result.toolName) assertNull(result.toolName)
@@ -470,11 +473,15 @@ class DomainEventMapperTest {
projectId = null, 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(requestId, result.requestId)
assertEquals("HIGH", result.riskSummary.level) assertEquals("HIGH", result.riskSummary.level)
assertEquals("PROMPT_USER", result.riskSummary.recommendedAction) 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) assertEquals(2, result.riskSummary.factors.size)
assertTrue(result.riskSummary.factors[0].contains("Validation errors")) assertTrue(result.riskSummary.factors[0].contains("Validation errors"))
assertTrue(result.riskSummary.factors[1].contains("Repeated failure")) assertTrue(result.riskSummary.factors[1].contains("Repeated failure"))
@@ -495,7 +502,8 @@ class DomainEventMapperTest {
userSteering = null, 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(sessionId, result.sessionId)
assertEquals(requestId, result.requestId) assertEquals(requestId, result.requestId)
assertEquals("APPROVED", result.outcome) assertEquals("APPROVED", result.outcome)
@@ -519,7 +527,8 @@ class DomainEventMapperTest {
userSteering = null, 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) assertEquals("REJECTED", result.outcome)
assertNull(result.reason) assertNull(result.reason)
} }
@@ -325,8 +325,14 @@ class SessionEventBridgeTest {
val snapshot = sent[0] as ServerMessage.SessionSnapshot val snapshot = sent[0] as ServerMessage.SessionSnapshot
// WorkflowStartedEvent is filtered out by eventToEntry (maps to null) // WorkflowStartedEvent is filtered out by eventToEntry (maps to null)
assertEquals(3, snapshot.recentEvents.size) assertEquals(3, snapshot.recentEvents.size)
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageStarted", "stage-2"), snapshot.recentEvents[0]) assertEquals(
assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "StageCompleted", stageId.value), snapshot.recentEvents[1]) 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]) assertEquals(EventEntryDto(timestamp.toEpochMilliseconds(), "SessionCompleted", ""), snapshot.recentEvents[2])
} }
@@ -359,7 +365,13 @@ class SessionEventBridgeTest {
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest { fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
val store = fakeEventStore(allEventsList = emptyList()) val store = fakeEventStore(allEventsList = emptyList())
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
assertEquals(1, sent.size) assertEquals(1, sent.size)
assertEquals(ServerMessage.SnapshotComplete, sent[0]) assertEquals(ServerMessage.SnapshotComplete, sent[0])
@@ -370,7 +382,13 @@ class SessionEventBridgeTest {
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L)) val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
val store = fakeEventStore(allEventsList = events) val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
assertEquals(ServerMessage.SnapshotComplete, sent.last()) assertEquals(ServerMessage.SnapshotComplete, sent.last())
} }
@@ -385,7 +403,13 @@ class SessionEventBridgeTest {
override suspend fun lastGlobalSequence(): Long = 5L override suspend fun lastGlobalSequence(): Long = 5L
} }
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(5L, snapshot.lastSequence) assertEquals(5L, snapshot.lastSequence)
@@ -399,7 +423,13 @@ class SessionEventBridgeTest {
) )
val store = fakeEventStore(allEventsList = events) val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } val bridge = SessionEventBridge(
store,
noopArtifactStore,
activeOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(3L, snapshot.lastSessionSequence) assertEquals(3L, snapshot.lastSessionSequence)
@@ -257,7 +257,10 @@ class FreestyleDriverTest {
assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint") assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint")
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single() val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}") assertTrue(
lint.hardFailures.any { it.code == "unproduced_need" },
"expected unproduced_need: ${lint.hardFailures}",
)
val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single() val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single()
assertEquals("lint", rejected.source) assertEquals("lint", rejected.source)
@@ -175,7 +175,8 @@ class ModelLifecycleLiveTest {
val raw = frame.readText() val raw = frame.readText()
when (val msg = decodeServerMessage(raw)) { when (val msg = decodeServerMessage(raw)) {
is ServerMessage.ModelChanged -> modelChanged = msg 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 else -> Unit
} }
} }
@@ -29,7 +29,11 @@ private class OnesEmbedder(override val dimension: Int = 8) : Embedder {
class ProjectMemoryServiceTest { 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 { runBlocking {
es.append( es.append(
NewEvent( NewEvent(
@@ -58,7 +62,11 @@ class ProjectMemoryServiceTest {
val sessionA = SessionId("A") val sessionA = SessionId("A")
store(sessionA, SteeringNoteAddedEvent(sessionA, "use jwt for auth"), eventStore) 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") 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") assertTrue(seeded.any { it.contains("use jwt for auth") }, "expected prior decision retrieved")
val note = eventStore.read(sessionB).mapNotNull { it.payload as? SteeringNoteAddedEvent }.firstOrNull() 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 @Test
@@ -109,7 +109,9 @@ class NarrationSubscriberTest {
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L)) liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("s0")), seq = 1L))
liveFlow.emit(storedEvent(StageCompletedEvent(sessionId, StageId("a"), TransitionId("t1")), seq = 2L)) 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)) liveFlow.emit(storedEvent(WorkflowCompletedEvent(sessionId, StageId("c"), totalStages = 2), seq = 4L))
withTimeout(2_000L) { withTimeout(2_000L) {
@@ -282,7 +282,9 @@ class ServerMessageSerializationTest {
val jsonStr = ProtocolSerializer.encodeServerMessage(msg) val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"workflow.proposed\"")) { "expected type=workflow.proposed" } assert(jsonStr.contains("\"type\":\"workflow.proposed\"")) { "expected type=workflow.proposed" }
assert(jsonStr.contains("\"workflowId\":\"research\"")) { "expected candidate id" } assert(jsonStr.contains("\"workflowId\":\"research\"")) { "expected candidate id" }
assert(jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\"")) { "expected original request" } assert(
jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\""),
) { "expected original request" }
val decoded = json.decodeFromString<ServerMessage.WorkflowProposed>(jsonStr) val decoded = json.decodeFromString<ServerMessage.WorkflowProposed>(jsonStr)
assertEquals(2, decoded.candidates.size) assertEquals(2, decoded.candidates.size)
@@ -56,8 +56,10 @@ class FileSystemWorkflowRegistryTest {
@Test @Test
fun `listAll falls back to workflowId when description absent`() { fun `listAll falls back to workflowId when description absent`() {
dir.resolve("healthcheck.toml").writeText(validToml.replace("description = \"Runs the health check pipeline\"\n", "")) val noDescToml = validToml.replace("description = \"Runs the health check pipeline\"\n", "")
assertEquals("healthcheck", FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir).listAll().single().description) dir.resolve("healthcheck.toml").writeText(noDescToml)
val registry = FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir)
assertEquals("healthcheck", registry.listAll().single().description)
} }
@Test @Test
@@ -87,10 +87,12 @@ class ReplayInspectionServiceTest {
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore { private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
override suspend fun append(event: NewEvent): StoredEvent = error("unused") override suspend fun append(event: NewEvent): StoredEvent = error("unused")
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused") override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId } override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence } events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence override fun lastSequence(sessionId: SessionId): Long? =
events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow() override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow() override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = 0L override suspend fun lastGlobalSequence(): Long = 0L
@@ -86,18 +86,26 @@ class EventInspectRoutesTest {
) )
private val seedEvents = listOf( private val seedEvents = listOf(
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"), 1L), storedEvent(
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"), 2L), 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), storedEvent(InitialIntentEvent(sessionId = s1, intent = "build the thing"), 3L),
) )
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore { private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
override suspend fun append(event: NewEvent): StoredEvent = error("unused") override suspend fun append(event: NewEvent): StoredEvent = error("unused")
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused") override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId } override fun read(sessionId: SessionId): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence } events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence override fun lastSequence(sessionId: SessionId): Long? =
events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow() override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow() override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = 0L override suspend fun lastGlobalSequence(): Long = 0L
@@ -180,7 +180,11 @@ class SessionUndoServiceTest {
val noWorkspaceEventStore = FakeEventStore(listOf(storedEvent(fileWritten, sessionId, 1L))) val noWorkspaceEventStore = FakeEventStore(listOf(storedEvent(fileWritten, sessionId, 1L)))
val serviceBootOnly = SessionUndoService(noWorkspaceEventStore, storeNoWorkspace, setOf(bootDir)) val serviceBootOnly = SessionUndoService(noWorkspaceEventStore, storeNoWorkspace, setOf(bootDir))
val summaryBootOnly = serviceBootOnly.undo(sessionId) val summaryBootOnly = serviceBootOnly.undo(sessionId)
assertEquals(1, summaryBootOnly.rejected, "boot-only roots must jail the workspace-dir file when no bound event exists") assertEquals(
1,
summaryBootOnly.rejected,
"boot-only roots must jail the workspace-dir file when no bound event exists",
)
} }
/** /**
@@ -121,7 +121,13 @@ class GlobalStreamHandlerTest {
orchRepo: OrchestrationRepository, orchRepo: OrchestrationRepository,
): Pair<SessionEventBridge, Channel<ServerMessage>> { ): Pair<SessionEventBridge, Channel<ServerMessage>> {
val received = Channel<ServerMessage>(Channel.UNLIMITED) val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, noopWorkflowRegistry, noopToolRegistry) { received.send(it) } val bridge = SessionEventBridge(
store,
noopArtifactStore,
orchRepo,
noopWorkflowRegistry,
noopToolRegistry,
) { received.send(it) }
return bridge to received return bridge to received
} }
@@ -167,7 +173,13 @@ class GlobalStreamHandlerTest {
val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 64) val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 64)
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L) val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L)
val received = Channel<ServerMessage>(Channel.UNLIMITED) val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { val bridge = SessionEventBridge(
store,
noopArtifactStore,
idleOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) {
received.send(it) received.send(it)
} }
val mapper = DomainEventMapper(noopArtifactStore) val mapper = DomainEventMapper(noopArtifactStore)
@@ -209,7 +221,13 @@ class GlobalStreamHandlerTest {
val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 16) val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 16)
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
val received = Channel<ServerMessage>(Channel.UNLIMITED) val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { val bridge = SessionEventBridge(
store,
noopArtifactStore,
idleOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) {
received.send(it) received.send(it)
} }
val mapper = DomainEventMapper(noopArtifactStore) val mapper = DomainEventMapper(noopArtifactStore)
@@ -257,7 +275,13 @@ class GlobalStreamHandlerTest {
return 0L return 0L
} }
} }
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), noopWorkflowRegistry, noopToolRegistry) { } val bridge = SessionEventBridge(
store,
noopArtifactStore,
idleOrchestrationRepository(),
noopWorkflowRegistry,
noopToolRegistry,
) { }
val mapper = DomainEventMapper(noopArtifactStore) val mapper = DomainEventMapper(noopArtifactStore)
val job = launch { val job = launch {
@@ -274,7 +298,13 @@ class GlobalStreamHandlerTest {
fun `shutdown - cancel outer scope ends subscription with no leaks`() = runTest { fun `shutdown - cancel outer scope ends subscription with no leaks`() = runTest {
val liveFlow = MutableSharedFlow<StoredEvent>() val liveFlow = MutableSharedFlow<StoredEvent>()
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) 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 mapper = DomainEventMapper(noopArtifactStore)
val job = launch { val job = launch {
@@ -250,7 +250,8 @@ class WorkspaceHandshakeTest {
delay(50) delay(50)
// Phase 2: start a session // 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) // Give the server time to process the event emission (before orchestrator runs)
delay(200) delay(200)
@@ -298,7 +299,8 @@ class WorkspaceHandshakeTest {
delay(50) delay(50)
// StartSession — this closes the Hello window // 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) delay(100)
// Late Hello with a different path — must be ignored // Late Hello with a different path — must be ignored
@@ -334,7 +336,8 @@ class WorkspaceHandshakeTest {
delay(100) delay(100)
// No Hello — send StartSession directly // 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) delay(200)
} }
} }
@@ -19,7 +19,11 @@ package com.correx.core.artifacts.kind
object KindContractTable { object KindContractTable {
/** A single assertion template — a kind's requirement before it is bound to a concrete path. */ /** A single assertion template — a kind's requirement before it is bound to a concrete path. */
private data class Template(val id: String, val evaluator: AssertionEvaluator, val args: Map<String, String> = emptyMap()) private data class Template(
val id: String,
val evaluator: AssertionEvaluator,
val args: Map<String, String> = emptyMap(),
)
private val FLOOR = listOf( private val FLOOR = listOf(
Template("file_exists", AssertionEvaluator.FS), Template("file_exists", AssertionEvaluator.FS),
@@ -104,7 +104,10 @@ internal object SimpleToml {
ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true } ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true }
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) } (ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) }
ch == quoteChar && inQuotes -> { inQuotes = false; 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) else -> current.append(ch)
} }
} }
@@ -181,6 +184,46 @@ object ProfileLoader {
} }
object ConfigLoader { 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 { fun load(): CorrexConfig {
val path = configPath() val path = configPath()
if (!Files.exists(path)) { if (!Files.exists(path)) {
@@ -302,7 +345,7 @@ object ConfigLoader {
} }
// JSON-style array: ["item1", "item2"] // JSON-style array: ["item1", "item2"]
valueStr.startsWith("[") && valueStr.endsWith("]") -> { valueStr.startsWith("[") && valueStr.endsWith("]") -> {
parseArray(valueStr, lineNum) parseArray(valueStr)
} }
// CSV fallback for backward compat (detect by "," and "=" pattern without brackets) // CSV fallback for backward compat (detect by "," and "=" pattern without brackets)
valueStr.contains(",") && valueStr.contains("=") && !valueStr.startsWith("\"") -> { valueStr.contains(",") && valueStr.contains("=") && !valueStr.startsWith("\"") -> {
@@ -336,7 +379,7 @@ object ConfigLoader {
return result return result
} }
private fun parseArray(arrayStr: String, lineNum: Int): List<String> { private fun parseArray(arrayStr: String): List<String> {
val result = mutableListOf<String>() val result = mutableListOf<String>()
val content = arrayStr.substring(1, arrayStr.length - 1).trim() val content = arrayStr.substring(1, arrayStr.length - 1).trim()
if (content.isEmpty()) return result if (content.isEmpty()) return result
@@ -430,12 +473,12 @@ object ConfigLoader {
val server = ServerConfig( val server = ServerConfig(
host = asString(serverSection["host"], "localhost"), host = asString(serverSection["host"], "localhost"),
port = asInt(serverSection["port"], 8080), port = asInt(serverSection["port"], DEFAULT_SERVER_PORT),
) )
val tui = TuiConfig( val tui = TuiConfig(
theme = asString(tuiSection["theme"], "dark"), 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( val cli = CliConfig(
@@ -447,7 +490,9 @@ object ConfigLoader {
val shellEnabled = when { val shellEnabled = when {
toolsShellSection.containsKey("enabled") -> asBoolean(toolsShellSection["enabled"], true) toolsShellSection.containsKey("enabled") -> asBoolean(toolsShellSection["enabled"], true)
toolsSection.containsKey("shell_enabled") -> { 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) asBoolean(toolsSection["shell_enabled"], true)
} }
else -> true else -> true
@@ -456,7 +501,9 @@ object ConfigLoader {
val fileReadEnabled = when { val fileReadEnabled = when {
toolsFileReadSection.containsKey("enabled") -> asBoolean(toolsFileReadSection["enabled"], true) toolsFileReadSection.containsKey("enabled") -> asBoolean(toolsFileReadSection["enabled"], true)
toolsSection.containsKey("file_read_enabled") -> { 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) asBoolean(toolsSection["file_read_enabled"], true)
} }
else -> true else -> true
@@ -465,7 +512,9 @@ object ConfigLoader {
val fileWriteEnabled = when { val fileWriteEnabled = when {
toolsFileWriteSection.containsKey("enabled") -> asBoolean(toolsFileWriteSection["enabled"], true) toolsFileWriteSection.containsKey("enabled") -> asBoolean(toolsFileWriteSection["enabled"], true)
toolsSection.containsKey("file_write_enabled") -> { 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) asBoolean(toolsSection["file_write_enabled"], true)
} }
else -> true else -> true
@@ -474,7 +523,9 @@ object ConfigLoader {
val fileEditEnabled = when { val fileEditEnabled = when {
toolsFileEditSection.containsKey("enabled") -> asBoolean(toolsFileEditSection["enabled"], true) toolsFileEditSection.containsKey("enabled") -> asBoolean(toolsFileEditSection["enabled"], true)
toolsSection.containsKey("file_edit_enabled") -> { 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) asBoolean(toolsSection["file_edit_enabled"], true)
} }
else -> true else -> true
@@ -491,7 +542,9 @@ object ConfigLoader {
val1 is String -> { val1 is String -> {
// Backward compat: check if it's CSV or already a single item // Backward compat: check if it's CSV or already a single item
if (val1.contains(",")) { 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() } val1.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} else { } else {
listOf(val1) listOf(val1)
@@ -547,7 +600,7 @@ object ConfigLoader {
val embedder = EmbedderConfig( val embedder = EmbedderConfig(
backend = asString(routerEmbedderSection["backend"], "noop"), backend = asString(routerEmbedderSection["backend"], "noop"),
dimension = asInt(routerEmbedderSection["dimension"], 1536), dimension = asInt(routerEmbedderSection["dimension"], DEFAULT_EMBEDDER_DIMENSION),
url = asStringOrNull(routerEmbedderSection["url"]), url = asStringOrNull(routerEmbedderSection["url"]),
modelId = asStringOrNull(routerEmbedderSection["model_id"]), modelId = asStringOrNull(routerEmbedderSection["model_id"]),
) )
@@ -557,30 +610,30 @@ object ConfigLoader {
persistPath = asStringOrNull(routerL3Section["persist_path"]), persistPath = asStringOrNull(routerL3Section["persist_path"]),
pythonExecutable = asString(routerL3Section["python_executable"], "python3"), pythonExecutable = asString(routerL3Section["python_executable"], "python3"),
scriptPath = asStringOrNull(routerL3Section["script_path"]), scriptPath = asStringOrNull(routerL3Section["script_path"]),
dim = asInt(routerL3Section["dim"], 1536), dim = asInt(routerL3Section["dim"], DEFAULT_L3_DIM),
bitWidth = asInt(routerL3Section["bit_width"], 4), bitWidth = asInt(routerL3Section["bit_width"], DEFAULT_L3_BIT_WIDTH),
) )
val generation = GenerationSettings( val generation = GenerationSettings(
temperature = asDouble(routerGenerationSection["temperature"], 0.7), temperature = asDouble(routerGenerationSection["temperature"], DEFAULT_GENERATION_TEMPERATURE),
topP = asDouble(routerGenerationSection["top_p"], 0.9), topP = asDouble(routerGenerationSection["top_p"], DEFAULT_GENERATION_TOP_P),
maxTokens = asInt(routerGenerationSection["max_tokens"], 512), maxTokens = asInt(routerGenerationSection["max_tokens"], DEFAULT_GENERATION_MAX_TOKENS),
) )
val narration = NarrationSettings( val narration = NarrationSettings(
temperature = asDouble(routerNarrationSection["temperature"], 0.7), temperature = asDouble(routerNarrationSection["temperature"], DEFAULT_NARRATION_TEMPERATURE),
topP = asDouble(routerNarrationSection["top_p"], 0.9), topP = asDouble(routerNarrationSection["top_p"], DEFAULT_NARRATION_TOP_P),
maxTokens = asInt(routerNarrationSection["max_tokens"], 4096), maxTokens = asInt(routerNarrationSection["max_tokens"], DEFAULT_NARRATION_MAX_TOKENS),
maxPerRun = asInt(routerNarrationSection["max_per_run"], 100), maxPerRun = asInt(routerNarrationSection["max_per_run"], DEFAULT_NARRATION_MAX_PER_RUN),
modelId = asStringOrNull(routerNarrationSection["model_id"]), modelId = asStringOrNull(routerNarrationSection["model_id"]),
) )
val router = TalkieConfig( val router = TalkieConfig(
embedder = embedder, embedder = embedder,
l3 = l3, l3 = l3,
conversationKeepLast = asInt(routerSection["conversation_keep_last"], 6), conversationKeepLast = asInt(routerSection["conversation_keep_last"], DEFAULT_CONVERSATION_KEEP_LAST),
retrievalK = asInt(routerSection["retrieval_k"], 5), retrievalK = asInt(routerSection["retrieval_k"], DEFAULT_RETRIEVAL_K),
tokenBudget = asInt(routerSection["token_budget"], 4096), tokenBudget = asInt(routerSection["token_budget"], DEFAULT_TOKEN_BUDGET),
generation = generation, generation = generation,
narration = narration, narration = narration,
) )
@@ -588,7 +641,7 @@ object ConfigLoader {
val models = modelsList.mapNotNull { modelMap -> val models = modelsList.mapNotNull { modelMap ->
val id = asString(modelMap["id"]) ?: return@mapNotNull null val id = asString(modelMap["id"]) ?: return@mapNotNull null
val modelPath = asString(modelMap["model_path"]) ?: 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") @Suppress("UNCHECKED_CAST")
val params = (modelMap["params"] as? Map<String, Any>) val params = (modelMap["params"] as? Map<String, Any>)
?.mapValues { it.value.toString() } ?.mapValues { it.value.toString() }
@@ -614,42 +667,53 @@ object ConfigLoader {
val project = ProjectConfig( val project = ProjectConfig(
enabled = asBoolean(projectSection["enabled"], false), enabled = asBoolean(projectSection["enabled"], false),
root = asString(projectSection["root"], ""), root = asString(projectSection["root"], ""),
memoryK = asInt(projectSection["memory_k"], 5), memoryK = asInt(projectSection["memory_k"], DEFAULT_PROJECT_MEMORY_K),
maxDepth = asInt(projectSection["max_depth"], 4), maxDepth = asInt(projectSection["max_depth"], DEFAULT_PROJECT_MAX_DEPTH),
ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES }, 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 orchestrationSection = sections["orchestration"] ?: emptyMap()
val orchestration = OrchestrationKnobs( val orchestration = OrchestrationKnobs(
stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], 180_000), stageTimeoutMs = asLong(orchestrationSection["stage_timeout_ms"], DEFAULT_STAGE_TIMEOUT_MS),
journalCompactionTokenThreshold = journalCompactionTokenThreshold = asInt(
asInt(orchestrationSection["journal_compaction_token_threshold"], 2_000), orchestrationSection["journal_compaction_token_threshold"],
resumeAbandonedMaxAgeMinutes = DEFAULT_JOURNAL_COMPACTION_TOKEN_THRESHOLD,
asLong(orchestrationSection["resume_abandoned_max_age_minutes"], 1_440), ),
compressionLevel = asInt(orchestrationSection["compression_level"], 4), resumeAbandonedMaxAgeMinutes = asLong(
orchestrationSection["resume_abandoned_max_age_minutes"],
DEFAULT_RESUME_ABANDONED_MAX_AGE_MINUTES,
),
compressionLevel = asInt(orchestrationSection["compression_level"], DEFAULT_COMPRESSION_LEVEL),
tokenPrunerUrl = tokenPrunerUrl =
asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"), asString(orchestrationSection["token_pruner_url"], "http://127.0.0.1:8199"),
maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], 30), maxToolRounds = asInt(orchestrationSection["max_tool_rounds"], DEFAULT_MAX_TOOL_ROUNDS),
readLoopNudgeThreshold = asInt(orchestrationSection["read_loop_nudge_threshold"], 3), readLoopNudgeThreshold =
rejectionLoopNudgeThreshold = asInt(orchestrationSection["rejection_loop_nudge_threshold"], 3), asInt(orchestrationSection["read_loop_nudge_threshold"], DEFAULT_READ_LOOP_NUDGE_THRESHOLD),
maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], 3), rejectionLoopNudgeThreshold = asInt(
repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], 30), orchestrationSection["rejection_loop_nudge_threshold"],
repoMapFilesPerDir = asInt(orchestrationSection["repo_map_files_per_dir"], 8), DEFAULT_REJECTION_LOOP_NUDGE_THRESHOLD,
docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], 20), ),
maxClarificationRounds = asInt(orchestrationSection["max_clarification_rounds"], 3), maxFeedbackIssues = asInt(orchestrationSection["max_feedback_issues"], DEFAULT_MAX_FEEDBACK_ISSUES),
reviewBlockMinConfidence = asDouble(orchestrationSection["review_block_min_confidence"], 0.7), repoMapInjectTopK = asInt(orchestrationSection["repo_map_inject_top_k"], DEFAULT_REPO_MAP_INJECT_TOP_K),
reviewBlockRetryCap = asInt(orchestrationSection["review_block_retry_cap"], 20), repoMapFilesPerDir =
defaultMaxRefinement = asInt(orchestrationSection["default_max_refinement"], 3), asInt(orchestrationSection["repo_map_files_per_dir"], DEFAULT_REPO_MAP_FILES_PER_DIR),
recoveryRouteBudget = asInt(orchestrationSection["recovery_route_budget"], 2), docsCatalogMax = asInt(orchestrationSection["docs_catalog_max"], DEFAULT_DOCS_CATALOG_MAX),
intentRouteBudget = asInt(orchestrationSection["intent_route_budget"], 2), 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( val modelsSettings = ModelsSettings(
defaultModel = asStringOrNull(modelsSection["default_model"]), defaultModel = asStringOrNull(modelsSection["default_model"]),
llamaServerBin = asString(modelsSection["llama_server_bin"], "llama-server"), llamaServerBin = asString(modelsSection["llama_server_bin"], "llama-server"),
host = asString(modelsSection["host"], "127.0.0.1"), 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 -> val artifacts = artifactsList.mapNotNull { artifactMap ->
@@ -664,8 +728,8 @@ object ConfigLoader {
val samplingSection = sections["sampling"] ?: emptyMap() val samplingSection = sections["sampling"] ?: emptyMap()
val sampling = SamplingConfig( val sampling = SamplingConfig(
temperature = asDouble(samplingSection["temperature"], 0.7), temperature = asDouble(samplingSection["temperature"], DEFAULT_SAMPLING_TEMPERATURE),
topP = asDouble(samplingSection["top_p"], 1.0), topP = asDouble(samplingSection["top_p"], DEFAULT_SAMPLING_TOP_P),
topK = samplingSection["top_k"]?.let { asInt(it) }, topK = samplingSection["top_k"]?.let { asInt(it) },
minP = samplingSection["min_p"]?.let { asDouble(it) }, minP = samplingSection["min_p"]?.let { asDouble(it) },
repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) }, repeatPenalty = samplingSection["repeat_penalty"]?.let { asDouble(it) },
@@ -184,7 +184,11 @@ object EditableConfig {
"orchestration.resume_abandoned_max_age_minutes", ConfigFieldType.LONG, "orchestration.resume_abandoned_max_age_minutes", ConfigFieldType.LONG,
getString = { it.orchestration.resumeAbandonedMaxAgeMinutes.toString() }, getString = { it.orchestration.resumeAbandonedMaxAgeMinutes.toString() },
withString = { c, v -> withString = { c, v ->
orc(c) { it.copy(resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)) } orc(c) {
it.copy(
resumeAbandonedMaxAgeMinutes = lng("orchestration.resume_abandoned_max_age_minutes", v)
)
}
}, },
), ),
) )
@@ -17,7 +17,9 @@ object OperatorProfileWriter {
} }
val prefs = profile.preferences val 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 (hasPrefs) {
if (b.isNotEmpty()) b.append('\n') if (b.isNotEmpty()) b.append('\n')
b.append("[preferences]\n") b.append("[preferences]\n")
@@ -97,8 +97,11 @@ class DefaultContextPackBuilder(
// Stage 1 (FORMAT_COMPRESS): lossless json→dotted-line compaction of structured entries. // Stage 1 (FORMAT_COMPRESS): lossless json→dotted-line compaction of structured entries.
val formatted = if (policy.enabled(CompressionStage.FORMAT_COMPRESS)) { val formatted = if (policy.enabled(CompressionStage.FORMAT_COMPRESS)) {
deduped.map { entry -> deduped.map { entry ->
if (classifier.classify(entry) == ContextClass.STRUCTURED) reencode(entry, formatCompressor.compress(entry.content)) if (classifier.classify(entry) == ContextClass.STRUCTURED) {
else entry reencode(entry, formatCompressor.compress(entry.content))
} else {
entry
}
} }
} else deduped } else deduped
@@ -142,7 +142,11 @@ class CompressionPipelineStagesTest {
// Instruction-doc pruning is retired: curated/procedural docs (project profile, AGENTS.md // 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. // how-to) must reach the model verbatim — token-pruning fused them into unparseable soup.
val pruner = object : com.correx.core.context.compression.TokenPruner { val pruner = object : com.correx.core.context.compression.TokenPruner {
override suspend fun prune(content: String, protectedSpans: List<String>, targetRatio: Double): String = "DOC-PRUNED" override suspend fun prune(
content: String,
protectedSpans: List<String>,
targetRatio: Double,
): String = "DOC-PRUNED"
} }
val builder = com.correx.core.context.builder.DefaultContextPackBuilder( val builder = com.correx.core.context.builder.DefaultContextPackBuilder(
com.correx.core.context.compression.DefaultContextCompressor(), CompressionPolicy(3), tokenPruner = pruner, 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"), entry("agi", ContextLayer.L0, EntryRole.SYSTEM, bigInstructions).copy(sourceType = "agentInstructions"),
) )
val flat = builder.build( val flat = builder.build(
com.correx.core.events.types.ContextPackId("p"), com.correx.core.events.types.SessionId("s"), com.correx.core.events.types.ContextPackId("p"),
com.correx.core.events.types.StageId("st"), entries, com.correx.core.context.model.TokenBudget(limit = 4000), 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() ).layers.values.flatten()
assertEquals(bigProfile, flat.first { it.sourceType == "projectProfile" }.content) assertEquals(bigProfile, flat.first { it.sourceType == "projectProfile" }.content)
assertEquals("you are an agent", flat.first { it.sourceType == "systemPrompt" }.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("toolLog", ContextLayer.L2, EntryRole.TOOL)))
assertEquals(ContextClass.STRUCTURED, c.classify(entry("steeringNote", ContextLayer.L2, EntryRole.SYSTEM))) assertEquals(ContextClass.STRUCTURED, c.classify(entry("steeringNote", ContextLayer.L2, EntryRole.SYSTEM)))
// Assistant tool-call turns are structured history, never token-pruned (amnesia-loop fix). // 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))) assertEquals(ContextClass.FREEFORM, c.classify(entry("chat", ContextLayer.L1, EntryRole.USER)))
} }
} }
@@ -3,14 +3,16 @@ package com.correx.core.approvals
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Suppress("MagicNumber")
@Serializable @Serializable
enum class Tier(val level: Int) { enum class Tier {
@SerialName("T0") T0(0), @SerialName("T0") T0,
@SerialName("T1") T1(1), @SerialName("T1") T1,
@SerialName("T2") T2(2), @SerialName("T2") T2,
@SerialName("T3") T3(3), @SerialName("T3") T3,
@SerialName("T4") T4(4) @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 fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level
@@ -28,6 +28,10 @@ class ArtifactRepairEventSerializationTest {
@Test @Test
fun `ArtifactRepairFailed round-trips`() { 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"),
),
)
} }
} }
@@ -14,7 +14,11 @@ class PlanLintCompletedEventSerializationTest {
sessionId = SessionId("sess-1"), sessionId = SessionId("sess-1"),
candidateId = "freestyle-sess-1", candidateId = "freestyle-sess-1",
hardFailures = listOf( 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'"), PlanLintFinding(code = "trap_state", stageId = "loop", detail = "no path to 'done'"),
), ),
softFindings = listOf( softFindings = listOf(
@@ -44,7 +44,11 @@ class RepoKnowledgeRetrievedEventSerializationTest {
query = "context builder", query = "context builder",
hits = listOf( hits = listOf(
RepoKnowledgeHit(path = "core/context/ContextBuilder.kt", text = "class ContextBuilder", score = 0.95f), 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(), assertEquals(sample, eventJson.decodeFromString(EventPayload.serializer(),
@@ -28,7 +28,8 @@ class RepoMapComputedEventSerializationTest {
@Test @Test
fun `RepoMapComputedEvent without stateKey field decodes with null`() { 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 val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) as RepoMapComputedEvent
assertNull(decoded.stateKey) assertNull(decoded.stateKey)
} }
@@ -23,7 +23,12 @@ class ToolCallAssessedEventSerializationTest {
observations = listOf( observations = listOf(
ToolCallObservation( ToolCallObservation(
ruleCode = "PATH_CONTAINMENT", 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, disposition = RiskAction.PROMPT_USER,
@@ -54,7 +54,12 @@ class DecisionJournalRenderer(private val keepLast: Int = 40) {
* agents don't act on "Approval APPROVED (tier T2)" or "Advanced x → y", so they're * 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). * dropped from the render (still present in [DecisionJournalState.records] for the journal).
*/ */
private val AGENT_RELEVANT_KINDS = private val AGENT_RELEVANT_KINDS = setOf(
setOf(DecisionKind.INTENT, DecisionKind.STEERING, DecisionKind.PREEMPT, DecisionKind.FAILURE, DecisionKind.RETRY) DecisionKind.INTENT,
DecisionKind.STEERING,
DecisionKind.PREEMPT,
DecisionKind.FAILURE,
DecisionKind.RETRY,
)
} }
} }
@@ -81,12 +81,14 @@ class FileSystemContractEvaluator : ContractAssertionEvaluator {
private fun scriptDefined(path: Path, exists: Boolean, name: String): ContractAssertionVerdict { private fun scriptDefined(path: Path, exists: Boolean, name: String): ContractAssertionVerdict {
val scripts = jsonObj(path, exists)?.get("scripts")?.let { runCatching { it.jsonObject }.getOrNull() } val scripts = jsonObj(path, exists)?.get("scripts")?.let { runCatching { it.jsonObject }.getOrNull() }
?: return verdict(false, "no scripts block") ?: 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 { private fun contains(path: Path, exists: Boolean, pattern: String): ContractAssertionVerdict {
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist") 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 { private fun textMatch(path: Path, exists: Boolean, needle: String, failMsg: String): ContractAssertionVerdict {
@@ -158,7 +158,9 @@ class ReplayOrchestrator(
session: Session, session: Session,
): ReplayStepResult { ): ReplayStepResult {
log.debug("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${stageId.value}") 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( is StageExecutionResult.Success -> ReplayStepResult.Continue(
ctx.copy(stageCount = ctx.stageCount + 1), ctx.copy(stageCount = ctx.stageCount + 1),
) )
@@ -20,13 +20,17 @@ class ReplayInferenceProvider(
override val id: ProviderId = ProviderId("replay-provider") 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 val tokenizer: Tokenizer = object : Tokenizer {
override suspend fun tokenize(text: String): List<Token> = override suspend fun tokenize(text: String): List<Token> =
List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars List(text.chunked(CHARS_PER_TOKEN).size) { i -> Token(i) }
override suspend fun countTokens(text: String): Int = 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 { override suspend fun infer(request: InferenceRequest): InferenceResponse {
@@ -6,7 +6,8 @@ import org.junit.jupiter.api.Test
class ArtifactPolicyDecisionTest { 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 @Test
fun `unsafe always aborts, even with budget and approver`() { fun `unsafe always aborts, even with budget and approver`() {
@@ -51,7 +51,8 @@ class JournalCompactionServiceTest {
} }
@Test @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 svc = JournalCompactionService(fakeArtifactStore(), { "summary" }, tokenThreshold = { 100 })
val state = stateWithRecords( val state = stateWithRecords(
makeRecord(1, DecisionKind.INTENT), // HIGH makeRecord(1, DecisionKind.INTENT), // HIGH
@@ -108,6 +108,9 @@ class DefaultTalkieContextBuilder(
/** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */ /** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */
private const val MAX_IDEAS_IN_CONTEXT = 10 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( override suspend fun build(
@@ -389,7 +392,7 @@ class DefaultTalkieContextBuilder(
} }
private fun fallbackEstimate(content: String): Int { private fun fallbackEstimate(content: String): Int {
return (content.length / 4).coerceAtLeast(1) return (content.length / CHARS_PER_TOKEN_ESTIMATE).coerceAtLeast(1)
} }
/** /**
@@ -146,7 +146,12 @@ class DefaultTalkieFacade(
// Rebuild state so lastRetrievedMemory reflects this turn // Rebuild state so lastRetrievedMemory reflects this turn
state = routerRepository.getTalkieState(sessionId) 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) { if (contextPack.compressionMetadata.entriesDropped > 0) {
val truncNow = Clock.System.now() val truncNow = Clock.System.now()
@@ -195,7 +200,8 @@ class DefaultTalkieFacade(
"without producing an answer (likely spent on hidden reasoning). " + "without producing an answer (likely spent on hidden reasoning). " +
"Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again." "Raise [router.generation] max_tokens or lower the model's reasoning budget, then ask again."
} else { } else {
"The model returned an empty response (finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again." "The model returned an empty response " +
"(finish reason: ${inferenceResponse.finishReason::class.simpleName}). Try again."
} }
} }
@@ -33,13 +33,22 @@ data class TaskContextBundle(
appendLine("task $id [$status] ${title.orEmpty()}".trimEnd()) appendLine("task $id [$status] ${title.orEmpty()}".trimEnd())
goal?.let { appendLine("goal: $it") } goal?.let { appendLine("goal: $it") }
if (blocked) appendLine("BLOCKED — waiting on ${blockedBy.size} unfinished dependency(ies):") 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" } appendList("acceptance_criteria", acceptanceCriteria) { " - $it" }
if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}") 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("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("artifacts", artifacts) {
appendList("sessions", sessions) { " - ${it.targetId} (${it.type}) [${it.status}] ${it.intent.orEmpty()}".trimEnd() } 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("related", relatedLinks) { " - ${it.targetId} (${it.type})" }
appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" } appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" }
appendList("notes", notes) { " - [${it.author}] ${it.body}" } appendList("notes", notes) { " - [${it.author}] ${it.body}" }
@@ -118,7 +118,12 @@ class DefaultTaskReducerTest {
val state = reduceAll( val state = reduceAll(
created(), created(),
TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")), 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"), TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"),
) )
@@ -43,7 +43,8 @@ class ManifestContainmentRule : ToolCallRule {
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw) 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 resolvedReal = input.probe.resolveReal(resolvedInput)
val inWorkspace = resolvedReal.startsWith(workspaceReal) val inWorkspace = resolvedReal.startsWith(workspaceReal)
val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw val relative = if (inWorkspace) workspaceReal.relativize(resolvedReal).toString() else raw
@@ -35,7 +35,8 @@ class PathContainmentRule : ToolCallRule {
for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) { for (raw in candidatePathStrings(input.paramRoles, input.request.parameters)) {
val candidate = Path.of(raw) 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 resolvedReal = input.probe.resolveReal(resolvedInput)
val exists = input.probe.exists(resolvedInput) val exists = input.probe.exists(resolvedInput)
val privileged = privilegedReal.any { resolvedReal.startsWith(it) } val privileged = privilegedReal.any { resolvedReal.startsWith(it) }
@@ -26,7 +26,13 @@ class ReferenceExistsRuleTest {
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
private fun input(pathArg: String, probe: WorldProbe) = ToolCallAssessmentInput( 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), capabilities = setOf(ToolCapability.FILE_READ),
workspace = WorkspacePolicy(workspace, emptyList()), workspace = WorkspacePolicy(workspace, emptyList()),
probe = probe, probe = probe,
@@ -27,7 +27,13 @@ class StaleWriteRuleTest {
private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize() private fun abs(p: String): Path = Path.of(p).toAbsolutePath().normalize()
private fun input(onDisk: String, session: SessionContext) = ToolCallAssessmentInput( 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), capabilities = setOf(ToolCapability.FILE_WRITE),
workspace = WorkspacePolicy(workspace, emptyList()), workspace = WorkspacePolicy(workspace, emptyList()),
probe = FakeProbe(mapOf(abs(target) to onDisk)), probe = FakeProbe(mapOf(abs(target) to onDisk)),
@@ -23,7 +23,13 @@ class WriteScopeRuleTest {
} }
private fun input(pathArg: String, active: ActiveTask?) = ToolCallAssessmentInput( 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), capabilities = setOf(ToolCapability.FILE_WRITE),
workspace = WorkspacePolicy(workspace, emptyList()), workspace = WorkspacePolicy(workspace, emptyList()),
probe = FakeProbe(), probe = FakeProbe(),
@@ -34,7 +40,10 @@ class WriteScopeRuleTest {
@Test @Test
fun `no active task means nothing to enforce`() { 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 @Test
@@ -20,7 +20,10 @@ class ArtifactExtractionPipelineTest {
) )
private fun resolved(raw: String) = 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 @Test
fun `clean json passes through`() { fun `clean json passes through`() {
@@ -45,7 +45,10 @@ class JsonSchemaValidatorTest {
@Test @Test
fun `unknown property is allowed when additionalProperties is true`() { fun `unknown property is allowed when additionalProperties is true`() {
val open = schema.copy(additionalProperties = 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()) assertTrue(result.isEmpty())
} }
@@ -17,6 +17,7 @@ import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
private const val DEFAULT_REQUEST_TIMEOUT_MS = 60_000L private const val DEFAULT_REQUEST_TIMEOUT_MS = 60_000L
private const val ERROR_BODY_PREVIEW_LENGTH = 200
private fun defaultHttpClient(): HttpClient = HttpClient(CIO) { private fun defaultHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
@@ -125,9 +126,10 @@ class LlamaCppEmbedder(
return nestedResult return nestedResult
} }
val preview = responseBody.take(ERROR_BODY_PREVIEW_LENGTH)
throw IllegalStateException( throw IllegalStateException(
"Unexpected embedding response shape from $url. " + "Unexpected embedding response shape from $url. " +
"Response body (first 200 chars): ${responseBody.take(200)}" "Response body (first $ERROR_BODY_PREVIEW_LENGTH chars): $preview"
) )
} }
} }
@@ -260,7 +260,8 @@ class LlamaCppInferenceProvider(
when { when {
line.startsWith("id = ") -> id = line.removePrefix("id = ").trim() line.startsWith("id = ") -> id = line.removePrefix("id = ").trim()
line.startsWith("function.name = ") -> name = line.removePrefix("function.name = ").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) { return if (name != null && arguments != null) {
@@ -41,7 +41,11 @@ class SqliteEventStoreConcurrencyTest {
assertEquals(n, events.size, "Expected exactly $n events but got ${events.size}") assertEquals(n, events.size, "Expected exactly $n events but got ${events.size}")
val sessionSeqs = events.map { it.sessionSequence }.sorted() 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 } val globalSeqs = events.map { it.sequence }
assertEquals(globalSeqs.size, globalSeqs.toSet().size, "Global sequence values must all be unique") assertEquals(globalSeqs.size, globalSeqs.toSet().size, "Global sequence values must all be unique")
@@ -84,12 +84,17 @@ object InfrastructureModule {
private val defaultArtifactsRoot: String = private val defaultArtifactsRoot: String =
"${System.getProperty("user.home")}/.config/correx/artifacts" "${System.getProperty("user.home")}/.config/correx/artifacts"
private const val LLAMA_CODING_SCORE = 0.7
private const val LLAMA_REASONING_SCORE = 0.6
private const val LLAMA_SUMMARIZATION_SCORE = 0.8
private const val LLAMA_TOOL_CALLING_SCORE = 0.5
private val DEFAULT_LLAMA_CAPABILITIES: Set<CapabilityScore> = setOf( private val DEFAULT_LLAMA_CAPABILITIES: Set<CapabilityScore> = setOf(
CapabilityScore(ModelCapability.General, 1.0), CapabilityScore(ModelCapability.General, 1.0),
CapabilityScore(ModelCapability.Coding, 0.7), CapabilityScore(ModelCapability.Coding, LLAMA_CODING_SCORE),
CapabilityScore(ModelCapability.Reasoning, 0.6), CapabilityScore(ModelCapability.Reasoning, LLAMA_REASONING_SCORE),
CapabilityScore(ModelCapability.Summarization, 0.8), CapabilityScore(ModelCapability.Summarization, LLAMA_SUMMARIZATION_SCORE),
CapabilityScore(ModelCapability.ToolCalling, 0.5), CapabilityScore(ModelCapability.ToolCalling, LLAMA_TOOL_CALLING_SCORE),
) )
fun createEventStore(artifactStore: ArtifactStore, dbPath: String = defaultDbPath): EventStore { fun createEventStore(artifactStore: ArtifactStore, dbPath: String = defaultDbPath): EventStore {
@@ -326,6 +331,17 @@ object InfrastructureModule {
val repository = DefaultTalkieRepository(replayer) val repository = DefaultTalkieRepository(replayer)
val ideaReader = IdeaReader(eventStore) val ideaReader = IdeaReader(eventStore)
val contextBuilder = DefaultTalkieContextBuilder(config, tokenizer, ideaProvider = { ideaReader.activeIdeas() }) 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,
)
} }
} }
@@ -39,7 +39,8 @@ class FileWriteTool(
private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet() private val normalizedAllowedPaths: Set<Path> = allowedPaths.map { it.normalize().toAbsolutePath() }.toSet()
override val name: String = "file_write" override val name: String = "file_write"
override val description: String = "Write content to a file at the specified relative path (creates or overwrites). " + override val description: String =
"Write content to a file at the specified relative path (creates or overwrites). " +
"Writing to a nested path auto-creates any missing parent directories — there is no separate " + "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." "mkdir step. Do not write shell commands into a file's content."
override val parametersSchema: JsonObject = buildJsonObject { override val parametersSchema: JsonObject = buildJsonObject {
@@ -179,7 +179,9 @@ class ListDirTool(
private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String { private fun render(root: Path, entries: List<String>, truncated: Boolean, ignoredCount: Int): String {
val notes = buildList { val notes = buildList {
if (truncated) add("truncated at $MAX_ENTRIES entries; narrow the path or list a sub-directory") 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 { return buildString {
appendLine("<path>$root</path>") appendLine("<path>$root</path>")
@@ -203,14 +205,14 @@ class ListDirTool(
.filter { it != targetName && isSimilarName(it, targetName) } .filter { it != targetName && isSimilarName(it, targetName) }
.toList() .toList()
.sortedWith(compareByDescending<String> { commonPrefixLen(it, targetName) }.thenBy { it }) .sortedWith(compareByDescending<String> { commonPrefixLen(it, targetName) }.thenBy { it })
.take(3) .take(MAX_SIMILAR_SUGGESTIONS)
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
private fun isSimilarName(a: String, b: String): Boolean { private fun isSimilarName(a: String, b: String): Boolean {
val prefix = commonPrefixLen(a, b) 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 { private fun commonPrefixLen(a: String, b: String): Int {
@@ -238,6 +240,9 @@ class ListDirTool(
private companion object { private companion object {
const val MAX_ENTRIES = 400 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
} }
} }
@@ -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 // 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. // 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_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 * `.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("properties") {
putJsonObject("pattern") { putJsonObject("pattern") {
put("type", "string") 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") { putJsonObject("path") {
put("type", "string") put("type", "string")
@@ -80,13 +84,27 @@ class GlobTool(
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false) return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
} }
val pattern = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() } 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) val base = resolveSearchPath(request, workingDir)
if (!Files.isDirectory(base)) { 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") } val matcher = runCatching { FileSystems.getDefault().getPathMatcher("glob:$pattern") }
.getOrElse { return@withContext ToolResult.Failure(request.invocationId, "Invalid glob pattern: ${it.message}", recoverable = true) } .getOrElse {
return@withContext ToolResult.Failure(
request.invocationId,
"Invalid glob pattern: ${it.message}",
recoverable = true,
)
}
val hits = ArrayList<String>() val hits = ArrayList<String>()
var truncated = false var truncated = false
@@ -147,16 +165,36 @@ class GrepTool(
return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false) return@withContext ToolResult.Failure(request.invocationId, it.reason, recoverable = false)
} }
val patternStr = (request.parameters["pattern"] as? String)?.takeIf { it.isNotBlank() } 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) } 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) val base = resolveSearchPath(request, workingDir)
if (!Files.isDirectory(base)) { 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 { val fileFilter = (request.parameters["glob"] as? String)?.takeIf { it.isNotBlank() }?.let {
runCatching { FileSystems.getDefault().getPathMatcher("glob:$it") } runCatching { FileSystems.getDefault().getPathMatcher("glob:$it") }
.getOrElse { e -> return@withContext ToolResult.Failure(request.invocationId, "Invalid glob: ${e.message}", recoverable = true) } .getOrElse { e ->
return@withContext ToolResult.Failure(
request.invocationId,
"Invalid glob: ${e.message}",
recoverable = true,
)
}
} }
val hits = ArrayList<String>() val hits = ArrayList<String>()
@@ -169,7 +207,7 @@ class GrepTool(
if (text != null && !text.contains('\u0000')) { if (text != null && !text.contains('\u0000')) {
text.lineSequence().forEachIndexed { idx, line -> text.lineSequence().forEachIndexed { idx, line ->
if (!truncated && regex.containsMatchIn(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 if (hits.size >= MAX_RESULTS) truncated = true
} }
} }
@@ -69,7 +69,8 @@ class WorkspaceSearchToolsTest {
fun `grep respects an optional file glob and prunes gitignore`(): Unit = runBlocking { fun `grep respects an optional file glob and prunes gitignore`(): Unit = runBlocking {
val root = fixture() val root = fixture()
val tool = GrepTool(allowedPaths = setOf(root), workingDir = root) 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/hooks/useAuth.ts:"), out)
assertTrue(out.contains("src/main.ts:"), out) assertTrue(out.contains("src/main.ts:"), out)
assertFalse(out.contains("node_modules"), out) // gitignored assertFalse(out.contains("node_modules"), out) // gitignored
@@ -119,10 +119,19 @@ class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
} }
return null 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 { return duplicates.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } val listed = it.joinToString(", ") { d ->
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") "${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.",
)
} }
} }
@@ -75,7 +75,10 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
putJsonObject("depends_on") { putJsonObject("depends_on") {
put("type", "array") put("type", "array")
putJsonObject("items") { put("type", "string") } 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")) }) 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 } dedupFailure(request, project, specs, parent)?.let { return it }
val pid = ProjectId(project) 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) } val children = specs.map { service.createTask(pid, it.title, it.goal, it.acceptanceCriteria, it.affectedPaths) }
wireLinks(adjacency, children, parentTask) wireLinks(adjacency, children, parentTask)
recordProvenance(request, listOfNotNull(parentTask) + children) 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. */ /** Create the `DEPENDS_ON` edges between children, and (if any) the parent's epic edges. */
private suspend fun wireLinks(adjacency: List<List<Int>>, children: List<Task>, parentTask: Task?) { private suspend fun wireLinks(adjacency: List<List<Int>>, children: List<Task>, parentTask: Task?) {
adjacency.forEachIndexed { i, deps -> 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) { if (parentTask != null) {
children.forEach { child -> children.forEach { child ->
@@ -156,15 +163,26 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
if (!request.boolParam("force")) return if (!request.boolParam("force")) return
val reason = request.stringParam("force_reason") ?: return val reason = request.stringParam("force_reason") ?: return
created.forEach { 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. */ /** Intra-batch + against-board duplicate guard, mirroring [TaskCreateTool]; force needs a reason. */
private fun dedupFailure(request: ToolRequest, project: String, specs: List<TaskSpec>, parent: TaskSpec?): ToolResult.Failure? { private fun dedupFailure(
request: ToolRequest,
project: String,
specs: List<TaskSpec>,
parent: TaskSpec?,
): ToolResult.Failure? {
val titles = (listOfNotNull(parent?.title) + specs.map { it.title }) val titles = (listOfNotNull(parent?.title) + specs.map { it.title })
val within = titles.groupBy { normalize(it) }.filter { it.value.size > 1 }.keys 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")) { if (request.boolParam("force")) {
return if (request.stringParam("force_reason") == null) { return if (request.stringParam("force_reason") == null) {
fail(request, "force=true requires 'force_reason' explaining why a duplicate is acceptable.") 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 pid = ProjectId(project)
val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value } val clashes = titles.flatMap { t -> service.findDuplicates(pid, t) }.distinctBy { it.taskId.value }
return clashes.takeIf { it.isNotEmpty() }?.let { return clashes.takeIf { it.isNotEmpty() }?.let {
val listed = it.joinToString(", ") { d -> "${d.taskId.value} '${d.state.title.orEmpty()}' [${d.state.status.name}]" } val listed = it.joinToString(", ") { d ->
fail(request, "Possible duplicate(s): $listed. Reuse one (task_context/task_update), or force=true with force_reason.") "${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()) { val readyLine = if (readyIds.isEmpty()) {
"Nothing is ready yet — check the dependency graph." "Nothing is ready yet — check the dependency graph."
} else { } 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" + return "Decomposed into ${children.size} task(s)${if (parentTask != null) " under an epic" else ""}:\n" +
lines.joinToString("\n") + "\n" + readyLine 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" } (obj[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" }
private fun strList(obj: JsonObject, key: String): List<String> = private fun strList(obj: JsonObject, key: String): List<String> =
(obj[key] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) } ?: emptyList() (obj[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content?.takeIf(String::isNotBlank) }
?: emptyList()
/** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */ /** Top-level args are flattened to strings by the orchestrator, so re-parse the value as JSON. */
private fun elementOf(request: ToolRequest, name: String) = private fun elementOf(request: ToolRequest, name: String) =
@@ -253,7 +280,9 @@ class TaskDecomposeTool(private val service: TaskService) : Tool, ToolExecutor {
val deps = ArrayList<Int>() val deps = ArrayList<Int>()
for (d in s.deps) { for (d in s.deps) {
val j = refToIndex[d] ?: d.toIntOrNull()?.takeIf { it in specs.indices } 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.") if (j == i) return Edges.Err("task #$i ('${s.title}') depends on itself.")
deps.add(j) deps.add(j)
} }
@@ -35,7 +35,10 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
put("type", "string") put("type", "string")
put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.") 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 override val tier: Tier = Tier.T1
@@ -50,7 +53,9 @@ class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor {
val output = if (ready.isEmpty()) { val output = if (ready.isEmpty()) {
"no ready tasks" "no ready tasks"
} else { } 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( return ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
@@ -35,12 +35,18 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
override val parametersSchema: JsonObject = buildJsonObject { override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object") put("type", "object")
putJsonObject("properties") { 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") { putJsonObject("project") {
put("type", "string") put("type", "string")
put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.") 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")) }) put("required", buildJsonArray { add(JsonPrimitive("query")) })
} }
@@ -61,7 +67,9 @@ class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
val output = if (hits.isEmpty()) { val output = if (hits.isEmpty()) {
"no tasks match \"$query\"" "no tasks match \"$query\""
} else { } 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( return ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
@@ -69,23 +69,35 @@ class TaskUpdateTool(
} }
putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") } 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("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") { putJsonObject("link_type") {
put("type", "string") put("type", "string")
put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.") put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.")
} }
putJsonObject("link_kind") { putJsonObject("link_kind") {
put("type", "string") 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("note") { put("type", "string"); put("description", "Append an agent note.") }
putJsonObject("force") { putJsonObject("force") {
put("type", "boolean") 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") { putJsonObject("force_reason") {
put("type", "string") 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")) }) put("required", buildJsonArray { add(JsonPrimitive("id")) })
@@ -159,7 +171,10 @@ class TaskUpdateTool(
val unmet = service.blockers(taskId) val unmet = service.blockers(taskId)
if (unmet.isEmpty()) return null if (unmet.isEmpty()) return null
val listed = unmet.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } 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.",
)
} }
/** /**
@@ -88,7 +88,7 @@ class WebFetchTool(
val contentType = response.headers[HttpHeaders.ContentType] val contentType = response.headers[HttpHeaders.ContentType]
val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
when { when {
response.status.value in 300..399 -> response.status.value in HTTP_REDIRECT_MIN..HTTP_REDIRECT_MAX ->
fail( fail(
invocationId, invocationId,
"URL redirected (HTTP ${response.status.value}) to " + "URL redirected (HTTP ${response.status.value}) to " +
@@ -159,5 +159,7 @@ class WebFetchTool(
companion object { companion object {
const val DEFAULT_MAX_BYTES: Long = 10_000_000 // 10 MB — a 200MB "page" is an attack or a mistake 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 READ_CHUNK = 8_192
private const val HTTP_REDIRECT_MIN = 300
private const val HTTP_REDIRECT_MAX = 399
} }
} }
@@ -94,7 +94,10 @@ class TaskToolsTest {
// affected_paths survived the JSON parse (not dropped like listParam would on the flattened arg). // 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) assertEquals(listOf("apps/web/**"), service.getTask(TaskId("webui-2"))!!.state.affectedPaths)
// scaffold is the only ready task; the dependents and the epic are blocked. // 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(listOf("webui-2"), service.blockers(TaskId("webui-3")).map { it.taskId.value })
assertEquals(3, service.blockers(TaskId("webui-1")).size) assertEquals(3, service.blockers(TaskId("webui-1")).size)
// child implements the epic; epic depends on the child. // child implements the epic; epic depends on the child.
@@ -122,12 +125,14 @@ class TaskToolsTest {
@Test @Test
fun `task_decompose rejects a dependency cycle`() = runBlocking { 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( val result = decompose.execute(
request( request(
"task_decompose", "task_decompose",
mapOf( mapOf(
"project" to "webui", "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)) is ToolResult.Failure)
assertTrue(decompose.execute(request("task_decompose", dup + ("force" to true))) 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) assertTrue(forced is ToolResult.Success)
val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",") val id = (forced as ToolResult.Success).metadata.getValue("taskIds").substringBefore(",")
assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") }) assertTrue(service.getTask(TaskId(id))!!.state.notes.any { it.body.startsWith("[force]") })
@@ -254,8 +260,15 @@ class TaskToolsTest {
@Test @Test
fun `task_search ranks matches across projects and reports misses`() = runBlocking { 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(
create.execute(request("task_create", mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys"))) 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"))) val hit = search.execute(request("task_search", mapOf("query" to "jwt")))
assertTrue(hit is ToolResult.Success) 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 { 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"))) 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 is ToolResult.Failure)
assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true)) assertTrue((dup as ToolResult.Failure).reason.contains("duplicate", ignoreCase = true))
// force without a reason is rejected. // force without a reason is rejected.
val noReason = create.execute( 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 is ToolResult.Failure)
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason")) 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 assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) // not mutated
// force without a reason is rejected. // 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 is ToolResult.Failure)
assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason")) assertTrue((noReason as ToolResult.Failure).reason.contains("force_reason"))
assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status) assertEquals(TaskStatus.TODO, service.getTask(TaskId("auth-2"))!!.state.status)
@@ -341,12 +361,21 @@ class TaskToolsTest {
val forced = update.execute( val forced = update.execute(
request( request(
"task_update", "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")) assertTrue((forced as ToolResult.Success).output.contains("WARNING"))
assertEquals(TaskStatus.IN_PROGRESS, service.getTask(TaskId("auth-2"))!!.state.status) 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 @Test
@@ -41,7 +41,9 @@ object PlanLinter {
fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult = fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
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), softFindings = stageCount(graph) + fanOut(graph) + emptyBriefs(graph) + duplicateBriefs(graph),
) )
@@ -147,7 +147,10 @@ class PlanLinterTest {
start = "implement", start = "implement",
) )
val result = PlanLinter.lint(g) 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}") assertTrue(result.passed, "hard=${result.hardFailures}")
} }
+254
View File
@@ -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<string, unknown>,
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`
```
<stdout/stderr content>
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>/path/to/file.ts</path>
<type>file</type>
<content>
1: line one
2: line two
3: line three
...
</content>
(Showing lines 1-50 of 200. Use offset=51 to continue.)
```
If a context file (marked as `context` in config):
```
<system-reminder>
...
</system-reminder>
```
### 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_<id>` 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": "<formatted text>"}
```
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: "<formatted>"},
{inlineData: {mimeType, data: base64}}
]}
```
---
## Summary Table
| Tool | What Model Sees |
|------|----------------|
| **bash** | stdout/stderr + `Command exited with code N` |
| **read** (text) | XML-tagged `<path><type><content>` 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: ..." |