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