feat(router): rubber-duck idea capture + feed
The rubber-duck path emits a fenced {"ideas":[…]} block; Ideas.parse strips it
from the chat turn and onUserInput records an IdeaCapturedEvent each (CHAT only).
IdeaReader folds eventStore.allEvents() into the active cross-session board, and
DefaultRouterContextBuilder injects a capped "## Ideas" entry — router-only, so
workflow stages never see it. Extended SYSTEM_PROMPT option (d) to capture ideas.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package com.correx.core.router
|
||||
|
||||
import com.correx.core.events.events.IdeaCapturedEvent
|
||||
import com.correx.core.events.events.IdeaDiscardedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.router.model.Idea
|
||||
|
||||
/**
|
||||
* Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over
|
||||
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session
|
||||
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a
|
||||
* matching [IdeaDiscardedEvent] tombstones it (invariant #1 — the capture stays in the log).
|
||||
*/
|
||||
class IdeaReader(private val eventStore: EventStore) {
|
||||
|
||||
/** Active ideas (captured, not later discarded) across all sessions, newest first. */
|
||||
fun activeIdeas(): List<Idea> {
|
||||
val discarded = mutableSetOf<String>()
|
||||
val captured = mutableListOf<Idea>()
|
||||
eventStore.allEvents().forEach { stored ->
|
||||
when (val payload = stored.payload) {
|
||||
is IdeaDiscardedEvent -> discarded += payload.ideaId
|
||||
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return captured.filterNot { it.id in discarded }.sortedByDescending { it.capturedAtMs }
|
||||
}
|
||||
|
||||
/** The session that captured [ideaId], so a discard tombstone lands alongside its capture. */
|
||||
fun sessionOf(ideaId: String): SessionId? =
|
||||
eventStore.allEvents()
|
||||
.mapNotNull { it.payload as? IdeaCapturedEvent }
|
||||
.firstOrNull { it.ideaId == ideaId }
|
||||
?.sessionId
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.correx.core.router
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Parses an optional `ideas` block out of a router chat reply: the rubber-duck path may end its prose
|
||||
* with a fenced ```` ```json {"ideas":["…","…"]} ``` ```` block to save ideas that crystallized.
|
||||
* Lenient and fence-tolerant — scans every fenced block for the one carrying an `ideas` array, accepts
|
||||
* string items or `{text|idea}` objects, and any parse failure or empty array yields no ideas. Returns
|
||||
* the reply with that block stripped (so the operator reads only prose), mirroring [WorkflowProposals].
|
||||
*/
|
||||
object Ideas {
|
||||
|
||||
data class Parsed(val cleanText: String, val ideas: List<String>)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
private val fence = Regex("```(?:json)?\\s*(.*?)```", RegexOption.DOT_MATCHES_ALL)
|
||||
|
||||
fun parse(content: String): Parsed = runCatching {
|
||||
fence.findAll(content).firstNotNullOfOrNull { match ->
|
||||
val obj = runCatching { json.parseToJsonElement(match.groupValues[1].trim()).jsonObject }.getOrNull()
|
||||
val array = obj?.get("ideas")?.jsonArray ?: return@firstNotNullOfOrNull null
|
||||
val ideas = array.mapNotNull { element ->
|
||||
when (element) {
|
||||
is JsonObject -> (element["text"] ?: element["idea"])?.jsonPrimitive?.contentOrNull
|
||||
is JsonPrimitive -> element.contentOrNull
|
||||
else -> null
|
||||
}?.trim()?.ifBlank { null }
|
||||
}
|
||||
ideas.takeIf { it.isNotEmpty() }?.let { Parsed(content.removeRange(match.range).trim(), it) }
|
||||
} ?: Parsed(cleanText = content, ideas = emptyList())
|
||||
}.getOrElse { Parsed(cleanText = content, ideas = emptyList()) }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.router.model.Idea
|
||||
import com.correx.core.router.model.NarrationTrigger
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.router.model.RouterL2Entry
|
||||
@@ -44,6 +45,10 @@ interface RouterContextBuilder {
|
||||
class DefaultRouterContextBuilder(
|
||||
private val config: RouterConfig,
|
||||
private val tokenizer: Tokenizer? = null,
|
||||
// Cross-session ideas to surface to the router (router-only context). Read fresh each build so
|
||||
// newly-captured ideas appear on the next turn; injected via the constructor so the build()
|
||||
// interface — and its many test doubles — stay unchanged.
|
||||
private val ideaProvider: () -> List<Idea> = { emptyList() },
|
||||
) : RouterContextBuilder {
|
||||
|
||||
companion object {
|
||||
@@ -73,6 +78,15 @@ class DefaultRouterContextBuilder(
|
||||
point at the relevant conventions or commands;
|
||||
(c) if recalled cross-session memory shows similar prior work, say so and reference it;
|
||||
(d) otherwise offer to think the problem through together in chat.
|
||||
|
||||
Rubber-duck capture: while thinking a problem through in chat, when a concrete idea or
|
||||
decision crystallizes, capture it by ending your reply with a fenced json block:
|
||||
```json
|
||||
{"ideas":["<one idea per item, in the operator's framing>"]}
|
||||
```
|
||||
These are saved to the operator's idea board (also shown back to you under "## Ideas").
|
||||
Capture only genuinely new, concrete ideas — omit the block when nothing crystallized,
|
||||
and never re-capture an idea already under "## Ideas".
|
||||
""".trimIndent()
|
||||
|
||||
private val NARRATION_SYSTEM_PROMPT =
|
||||
@@ -86,6 +100,9 @@ class DefaultRouterContextBuilder(
|
||||
""".trimIndent()
|
||||
|
||||
private const val RECALLED_MEMORY_PREFIX = "[recalled memory]"
|
||||
|
||||
/** Cap on ideas folded into router context, newest-first, so the board can't balloon L0. */
|
||||
private const val MAX_IDEAS_IN_CONTEXT = 10
|
||||
}
|
||||
|
||||
override suspend fun build(
|
||||
@@ -131,10 +148,22 @@ class DefaultRouterContextBuilder(
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
val ideasEntry: ContextEntry? = ideaProvider()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let {
|
||||
buildContextEntry(
|
||||
sourceType = "ideas",
|
||||
sourceId = "operator-ideas",
|
||||
content = buildIdeasContent(it),
|
||||
layer = ContextLayer.L0,
|
||||
role = EntryRole.SYSTEM,
|
||||
)
|
||||
}
|
||||
|
||||
// Compute remaining budget after protected frame.
|
||||
val protectedTokens = systemPrompt.tokenEstimate + workflowStatusEntry.tokenEstimate +
|
||||
(availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0)
|
||||
(availableWorkflowsEntry?.tokenEstimate ?: 0) + (projectProfileEntry?.tokenEstimate ?: 0) +
|
||||
(ideasEntry?.tokenEstimate ?: 0)
|
||||
var remainingBudget = (budget.limit - protectedTokens).coerceAtLeast(0)
|
||||
|
||||
val allEntries = mutableListOf<ContextEntry>()
|
||||
@@ -142,6 +171,7 @@ class DefaultRouterContextBuilder(
|
||||
allEntries += workflowStatusEntry
|
||||
availableWorkflowsEntry?.let { allEntries += it }
|
||||
projectProfileEntry?.let { allEntries += it }
|
||||
ideasEntry?.let { allEntries += it }
|
||||
|
||||
var droppedCount = 0
|
||||
val truncatedLayers = mutableSetOf<ContextLayer>()
|
||||
@@ -316,6 +346,14 @@ class DefaultRouterContextBuilder(
|
||||
}
|
||||
}.trimEnd()
|
||||
|
||||
private fun buildIdeasContent(ideas: List<Idea>): String =
|
||||
buildString {
|
||||
append("## Ideas\n")
|
||||
append("Ideas the operator captured while thinking out loud. Reference them when relevant; ")
|
||||
append("they are notes, not instructions.\n")
|
||||
ideas.take(MAX_IDEAS_IN_CONTEXT).forEach { append("- ${it.text}\n") }
|
||||
}.trimEnd()
|
||||
|
||||
private suspend fun buildContextEntry(
|
||||
sourceType: String,
|
||||
sourceId: String,
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.ChatTurnRole
|
||||
import com.correx.core.events.events.ContextTruncatedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.IdeaCapturedEvent
|
||||
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||
import com.correx.core.events.events.L3RetrievedHit
|
||||
import com.correx.core.events.events.NewEvent
|
||||
@@ -156,12 +157,14 @@ class DefaultRouterFacade(
|
||||
)
|
||||
val inferenceResponse = provider.infer(inferenceRequest)
|
||||
val rawContent = inferenceResponse.text
|
||||
// The triage path may append a structured workflow proposal; strip it from the visible turn
|
||||
// so the operator reads prose, then record the candidates as a WorkflowProposedEvent below.
|
||||
// The triage path may append a structured workflow proposal, and the rubber-duck path an
|
||||
// `ideas` block; strip both from the visible turn so the operator reads only prose, then
|
||||
// record them as events below.
|
||||
val proposal = WorkflowProposals.parse(rawContent)
|
||||
val ideas = Ideas.parse(proposal.cleanText)
|
||||
// A length-finish with no visible text means the model burned the whole completion
|
||||
// budget on hidden reasoning — surface that instead of rendering a silent blank turn.
|
||||
val content = proposal.cleanText.ifBlank {
|
||||
val content = ideas.cleanText.ifBlank {
|
||||
if (inferenceResponse.finishReason is FinishReason.Length) {
|
||||
"The model used its entire ${config.generationConfig.maxTokens}-token response budget " +
|
||||
"without producing an answer (likely spent on hidden reasoning). " +
|
||||
@@ -180,6 +183,7 @@ class DefaultRouterFacade(
|
||||
|
||||
if (mode == ChatMode.CHAT) {
|
||||
emitWorkflowProposalIfAny(sessionId, input, proposal)
|
||||
emitIdeasCaptured(sessionId, ideas.ideas)
|
||||
}
|
||||
|
||||
val steeringEmitted = mode == ChatMode.STEERING && rawContent.isNotBlank()
|
||||
@@ -329,6 +333,31 @@ class DefaultRouterFacade(
|
||||
)
|
||||
}
|
||||
|
||||
/** Records one [IdeaCapturedEvent] per idea the operator surfaced while rubber-ducking. */
|
||||
private suspend fun emitIdeasCaptured(sessionId: SessionId, ideas: List<String>) {
|
||||
ideas.forEach { text ->
|
||||
val now = Clock.System.now()
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = now,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = IdeaCapturedEvent(
|
||||
ideaId = UUID.randomUUID().toString(),
|
||||
sessionId = sessionId,
|
||||
text = text,
|
||||
timestampMs = now.toEpochMilliseconds(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
||||
val now = Clock.System.now()
|
||||
eventStore.append(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.router.model
|
||||
|
||||
/** An active idea on the operator's cross-session scratchpad (a captured, not-yet-discarded note). */
|
||||
data class Idea(
|
||||
val id: String,
|
||||
val text: String,
|
||||
val capturedAtMs: Long,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.correx.core.router
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IdeasTest {
|
||||
|
||||
@Test
|
||||
fun `parses a fenced ideas block and strips it from the prose`() {
|
||||
val content = """
|
||||
Good point — two things worth remembering.
|
||||
|
||||
```json
|
||||
{"ideas":["cache the repo map across sessions","add a --dry-run flag"]}
|
||||
```
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = Ideas.parse(content)
|
||||
|
||||
assertEquals(listOf("cache the repo map across sessions", "add a --dry-run flag"), parsed.ideas)
|
||||
assertTrue(parsed.cleanText.startsWith("Good point"))
|
||||
assertTrue("```" !in parsed.cleanText, "fence must be stripped from the visible turn")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepts object items with a text field`() {
|
||||
val content = "prose\n```json\n{\"ideas\":[{\"text\":\"idea one\"},{\"idea\":\"idea two\"}]}\n```"
|
||||
val parsed = Ideas.parse(content)
|
||||
assertEquals(listOf("idea one", "idea two"), parsed.ideas)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finds the ideas block even when it is not the first fence`() {
|
||||
val content = "```json\n{\"propose_workflows\":[{\"id\":\"x\"}]}\n```\n" +
|
||||
"and\n```json\n{\"ideas\":[\"keep this\"]}\n```"
|
||||
val parsed = Ideas.parse(content)
|
||||
assertEquals(listOf("keep this"), parsed.ideas)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no block yields no ideas and untouched text`() {
|
||||
val content = "Let's just talk it through."
|
||||
val parsed = Ideas.parse(content)
|
||||
assertTrue(parsed.ideas.isEmpty())
|
||||
assertEquals(content, parsed.cleanText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank and empty items are dropped`() {
|
||||
val content = "x\n```json\n{\"ideas\":[\" \",\"real\",\"\"]}\n```"
|
||||
val parsed = Ideas.parse(content)
|
||||
assertEquals(listOf("real"), parsed.ideas)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import com.correx.core.inference.NoopEmbedder
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.router.DefaultRouterContextBuilder
|
||||
import com.correx.core.router.DefaultRouterFacade
|
||||
import com.correx.core.router.IdeaReader
|
||||
import com.correx.core.router.DefaultRouterReducer
|
||||
import com.correx.core.router.DefaultRouterRepository
|
||||
import com.correx.core.router.RouterFacade
|
||||
@@ -300,7 +301,8 @@ object InfrastructureModule {
|
||||
val projector = RouterProjector(reducer)
|
||||
val replayer = DefaultEventReplayer(eventStore, projector)
|
||||
val repository = DefaultRouterRepository(replayer)
|
||||
val contextBuilder = DefaultRouterContextBuilder(config, tokenizer)
|
||||
val ideaReader = IdeaReader(eventStore)
|
||||
val contextBuilder = DefaultRouterContextBuilder(config, tokenizer, ideaProvider = { ideaReader.activeIdeas() })
|
||||
return DefaultRouterFacade(repository, contextBuilder, inferenceRouter, eventStore, config, null, embedder, l3MemoryStore, workflowSummaryProvider, sessionProfileProvider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.correx.core.router.ChatMode
|
||||
import com.correx.core.router.DefaultRouterContextBuilder
|
||||
import com.correx.core.router.DefaultRouterFacade
|
||||
import com.correx.core.router.DefaultRouterReducer
|
||||
import com.correx.core.router.IdeaReader
|
||||
import com.correx.core.router.RouterContextBuilder
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.router.RouterProjector
|
||||
@@ -664,6 +665,50 @@ class RouterFacadeTest {
|
||||
assertTrue(store.appendedEvents.map { it.payload }.filterIsInstance<WorkflowProposedEvent>().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rubber-duck ideas are captured, readable cross-session, and removed by a discard`(): Unit = runBlocking {
|
||||
val store = mockEventStore()
|
||||
val reply = "Right — worth keeping.\n```json\n{\"ideas\":[\"cache the repo map\",\"add --dry-run\"]}\n```"
|
||||
val facade = facadeProposing(store, reply, emptyList())
|
||||
|
||||
facade.onUserInput(SessionId("chat-1"), "how do we speed this up?")
|
||||
|
||||
val reader = IdeaReader(store)
|
||||
val ideas = reader.activeIdeas()
|
||||
assertEquals(2, ideas.size)
|
||||
assertTrue(ideas.any { it.text == "cache the repo map" } && ideas.any { it.text == "add --dry-run" })
|
||||
|
||||
// The visible chat turn must not leak the json block.
|
||||
val routerTurn = store.appendedEvents.map { it.payload }
|
||||
.filterIsInstance<ChatTurnEvent>().first { it.role == ChatTurnRole.ROUTER }
|
||||
assertFalse(routerTurn.content.contains("```"))
|
||||
|
||||
// Discard one (from a *different* session) → it drops off the board, the other stays.
|
||||
val drop = ideas.first { it.text == "add --dry-run" }
|
||||
store.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("disc-1"), sessionId = SessionId("chat-2"),
|
||||
timestamp = Instant.parse("2026-01-01T00:00:00Z"), schemaVersion = 1, causationId = null, correlationId = null,
|
||||
),
|
||||
payload = com.correx.core.events.events.IdeaDiscardedEvent(ideaId = drop.id, sessionId = SessionId("chat-2")),
|
||||
),
|
||||
)
|
||||
val after = reader.activeIdeas()
|
||||
assertEquals(listOf("cache the repo map"), after.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a steering turn never captures ideas`(): Unit = runBlocking {
|
||||
val store = mockEventStore()
|
||||
val reply = "x\n```json\n{\"ideas\":[\"nope\"]}\n```"
|
||||
val facade = facadeProposing(store, reply, emptyList())
|
||||
|
||||
facade.onUserInput(SessionId("s"), "steer", ChatMode.STEERING)
|
||||
|
||||
assertTrue(IdeaReader(store).activeIdeas().isEmpty())
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user