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