feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events

- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
  as intent-holder, holds initial intent, reconciles cross-file contract disputes)
  with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
This commit is contained in:
2026-07-11 23:56:52 +04:00
parent 3d5e05c1fb
commit 15248cae8a
55 changed files with 1932 additions and 231 deletions
@@ -1,5 +1,10 @@
package com.correx.core.talkie
import com.correx.core.context.model.CompressionMetadata
import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.EntryRole
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.ChatTurnRole
import com.correx.core.events.events.ContextTruncatedEvent
@@ -10,9 +15,12 @@ import com.correx.core.events.events.L3RetrievedHit
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TalkieNarrationEvent
import com.correx.core.events.events.SessionNamedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId
@@ -42,6 +50,11 @@ private val log = LoggerFactory.getLogger(DefaultTalkieFacade::class.java)
private const val MAX_NARRATED_ACTIONS = 6
private const val ACTION_ARG_MAX = 60
// Session-naming caps.
private const val NAME_INTENT_MAX = 2000
private const val NAME_MAX_CHARS = 64
private const val TOKEN_CHARS = 4
interface TalkieFacade {
suspend fun onUserInput(
sessionId: SessionId,
@@ -50,6 +63,13 @@ interface TalkieFacade {
): TalkieResponse
suspend fun narrate(sessionId: SessionId, trigger: NarrationTrigger)
/**
* Derives a short, human-readable title for the session from its initial [intent] and records
* it once as a [SessionNamedEvent]. Nondeterministic (an LLM call) so the result is captured as
* an event, never re-derived on replay (invariant #9). No-op on blank intent or empty output.
*/
suspend fun nameSession(sessionId: SessionId, intent: String)
}
class DefaultTalkieFacade(
@@ -254,6 +274,80 @@ class DefaultTalkieFacade(
)
}
override suspend fun nameSession(sessionId: SessionId, intent: String) {
if (intent.isBlank()) return
val instruction = buildString {
append("Produce a short, specific title (3-6 words, Title Case, no quotes, no trailing ")
append("punctuation) that names the task described below. Reply with the title only.\n\n")
append("Task: ")
append(intent.trim().take(NAME_INTENT_MAX))
}
val contextPack = ContextPack(
id = ContextPackId("${sessionId.value}-naming-pack"),
sessionId = sessionId,
stageId = StageId.NONE,
layers = mapOf(
ContextLayer.L0 to listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = instruction,
sourceType = "sessionNaming",
sourceId = sessionId.value,
tokenEstimate = (instruction.length / TOKEN_CHARS).coerceAtLeast(1),
role = EntryRole.USER,
),
),
),
budgetUsed = (instruction.length / TOKEN_CHARS).coerceAtLeast(1),
budgetLimit = config.tokenBudget.limit,
compressionMetadata = CompressionMetadata(appliedStrategies = listOf("SessionNaming")),
)
val provider = inferenceRouter.route(StageId.NONE, setOf(ModelCapability.General), config.narrationModelId)
val inferenceRequest = InferenceRequest(
requestId = InferenceRequestId(UUID.randomUUID().toString()),
sessionId = sessionId,
stageId = StageId.NONE,
contextPack = contextPack,
generationConfig = config.narrationGenerationConfig,
responseFormat = ResponseFormat.Text,
)
val raw = provider.infer(inferenceRequest).text
val name = sanitizeSessionName(raw)
if (name.isBlank()) return
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 = SessionNamedEvent(
sessionId = sessionId,
name = name,
timestampMs = now.toEpochMilliseconds(),
),
),
)
}
/** Strips quotes/markdown/trailing punctuation and clamps a model-produced title to one line. */
private fun sanitizeSessionName(raw: String): String =
raw.lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotBlank() }
.orEmpty()
.trim('"', '\'', '`', '*', '#', ' ', '.', ':')
.take(NAME_MAX_CHARS)
.trim()
/**
* The most recent concrete tool actions in the session, read straight from the event log so
* the narrator can say what actually happened (files read/written, tools run) instead of a