package vip.mate.channel; import io.micrometer.core.instrument.Metrics; import lombok.extern.slf4j.Slf4j; import vip.mate.agent.ContentKind; import java.util.List; import java.util.Map; /** * Single authority for the "provisional narration" lifecycle shared by every * user-facing surface. * *
A {@link ContentKind#PRE_TOOL_NARRATION} span is written before any tool * observation of its turn, in a completion that goes on to call tools — it may * be process narration or a fully fabricated rehearsal of the result. The * policy, identical everywhere: * *
Grounded narrations and final answers never stage — they publish * directly. Producers that predate the kind tag emit {@code null} kinds; the * streaming API accepts a caller-supplied structural fallback signal for that * case, and the segment-marking API leaves untagged timelines to the legacy * structural detector. * *
Instances are single-turn and not thread-safe — create one per stream * consumption, confine to the consuming thread (matches how channel adapters * drain a turn's {@code Flux} today). */ @Slf4j public final class ProvisionalContentTracker { /** Marker value stored in {@code supersededReason} — same wire value the * legacy structural detector writes, so the UI needs no new vocabulary. */ public static final String REASON_PRE_TOOL_CONTENT_REPLACED = "pre_tool_content_replaced_by_post_tool_answer"; private static final String METRIC_SUPERSEDED = "mateclaw.narration.superseded"; /** Where the supersede happened — metric tag, one value per surface. */ private final String surface; /** Tool observations completed so far this turn (caller-reported). */ private int observations; /** Observation count at the time of the most recent staging. */ private int lastStageMark; private String pendingText; private boolean pendingProvisional; /** Observation count when the pending narration was staged. */ private int pendingMark; public ProvisionalContentTracker(String surface) { this.surface = surface; } /** Report a completed tool observation (a {@code tool_call_completed} event). */ public void onToolObservation() { observations++; } /** * Stage a per-round narration. Returns the previous staged * narration if the new arrival makes it publishable, or {@code null} when * there is nothing to publish (no previous, or the previous was * provisional and tool observations since its staging mean this later * content supersedes it). * * @param kind producer-assigned kind; {@code null} for pre-tag producers, * in which case a narration counts as provisional when no * observation completed since the previous staging (the * pre-tag online rule) */ public String stageNarration(String text, ContentKind kind) { boolean observedSinceLast = observations > lastStageMark; boolean provisional = kind != null ? kind == ContentKind.PRE_TOOL_NARRATION : !observedSinceLast; String previous = pendingText; boolean previousProvisional = pendingProvisional; int previousMark = pendingMark; pendingText = text; pendingProvisional = provisional; pendingMark = observations; lastStageMark = observations; if (previous == null) { return null; } if (previousProvisional && observations > previousMark) { recordSuperseded(previous); return null; } return previous; } /** * Resolve the staged narration at turn end. Returns the text to publish, * or {@code null} when nothing remains (no staged narration, or it was * provisional, tools ran after it, and the turn produced final content * that replaces it). * * @param hasFinalContent whether the turn produced a final answer — with * one, a provisional narration is superseded; with * none, even a provisional narration commits (no * replacement exists) */ public String settle(boolean hasFinalContent) { String text = pendingText; boolean provisional = pendingProvisional; int mark = pendingMark; pendingText = null; pendingProvisional = false; pendingMark = 0; if (text == null) { return null; } if (provisional && hasFinalContent && observations > mark) { recordSuperseded(text); return null; } return text; } private void recordSuperseded(String text) { log.info("[{}] provisional narration superseded by later content ({} chars dropped from permanent output)", surface, text.length()); Metrics.counter(METRIC_SUPERSEDED, "surface", surface).increment(); } // ==================== Persisted-timeline marking ==================== /** * Whether the persisted segments timeline carries producer-assigned kind * tags — i.e. whether {@link #markSuperseded(List, String)} is applicable * or the caller should fall back to structural detection. */ public static boolean hasKindTags(List