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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user