diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index 2f3bddedd..412597941 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -41,10 +41,12 @@ import { import { quotaWaitSeconds, shouldAutoRetryQuota } from "./quota-retry.js" import { applyStallRecovery, + repetitionRecoveryMessage, shouldAbortForStall, shouldNoticeStall, STALL_NOTICE_MESSAGE, STALL_NOTICE_MS, + STALL_RECOVERY_MESSAGE, STALL_TIMEOUT_MS, } from "./stall-watchdog.js" import { @@ -919,6 +921,27 @@ export function attachSessionBridge( return } + // Content-based, not time-based: a repeating line means the model is + // stuck regardless of how fast it is producing it, so this is checked + // before the silence clock rather than folded into it. + // + // Gated on `status === "running"` because every turn-ending transition + // (interrupt, connector.reply with no tools outstanding, reactor.done / + // reactor.error) routes through `initialTurnState`, which clears + // `repeating`. If a future settle path changes `isProcessing` without + // also resetting `status` and `repeating` through that same reset, this + // guard would no longer mean "the turn is actually live" and could fire + // on an already-settled turn — recheck this alongside any such change. + if (bag.turn.status === "running" && bag.turn.repeating) { + const repeatedTokens = + bag.turn.streamTokenCount - (bag.turn.repeatingSinceTokenCount ?? 0) + applyStallRecovery( + { abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) }, + repetitionRecoveryMessage(repeatedTokens), + ) + return + } + const stallArgs = { status: bag.turn.status, awaitingResponse: bag.turn.awaitingResponse, @@ -930,16 +953,22 @@ export function attachSessionBridge( } if (shouldAbortForStall(stallArgs)) { - applyStallRecovery({ - abort: doInterrupt, - notify: (message) => setStatusFlash(shell, message), - }) + applyStallRecovery( + { abort: doInterrupt, notify: (message) => setStatusFlash(shell, message) }, + STALL_RECOVERY_MESSAGE, + ) return } // Notice only — the phase still paints below, because a ramp that stops // moving is the very thing that reads as a hang. - if (shouldNoticeStall({ ...stallArgs, stallNoticeMs })) { + if ( + shouldNoticeStall({ + ...stallArgs, + stallNoticeMs, + repeating: bag.turn.repeating, + }) + ) { setStatusFlash(shell, STALL_NOTICE_MESSAGE) } diff --git a/src/tui-opentui/stall-watchdog.test.ts b/src/tui-opentui/stall-watchdog.test.ts index e3ace6a7f..885ae7115 100644 --- a/src/tui-opentui/stall-watchdog.test.ts +++ b/src/tui-opentui/stall-watchdog.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test" import { applyStallRecovery, + detectRepetition, + repetitionRecoveryMessage, shouldAbortForStall, shouldNoticeStall, STALL_NOTICE_MS, @@ -81,7 +83,7 @@ describe("shouldAbortForStall", () => { }) describe("applyStallRecovery", () => { - test("aborts then notifies", () => { + test("aborts then notifies with the default message", () => { const calls: string[] = [] applyStallRecovery({ abort: () => calls.push("abort"), @@ -89,6 +91,85 @@ describe("applyStallRecovery", () => { }) expect(calls).toEqual(["abort", STALL_RECOVERY_MESSAGE]) }) + + test("aborts then notifies with a supplied message", () => { + const calls: string[] = [] + applyStallRecovery( + { abort: () => calls.push("abort"), notify: (m) => calls.push(m) }, + "custom message", + ) + expect(calls).toEqual(["abort", "custom message"]) + }) +}) + +describe("detectRepetition", () => { + test("finds nothing in fresh, varied output", () => { + const text = [ + "I'll check the callId emission path first.", + "Running the search now.", + "Found three matches across the module.", + ].join("\n") + expect(detectRepetition(text).repeating).toBe(false) + }) + + // The captured incident: the two sentences ran together with no line break + // at all. A line-splitting detector never sees this; the period search + // does not care where (or whether) the lines break. + test("flags the captured incident string verbatim, with no newlines", () => { + const line1 = + "I'll verify callId emission and remaining edges, then write the ranked findings." + const line2 = "Confirming callId emission, then writing the ranked findings." + const text = Array(10).fill(`${line1}${line2}`).join("") + const check = detectRepetition(text) + expect(check.repeating).toBe(true) + expect(check.period).toBe(line1.length + line2.length) + }) + + test("does not flag the same cycle a handful of times", () => { + const line1 = + "I'll verify callId emission and remaining edges, then write the ranked findings." + const line2 = "Confirming callId emission, then writing the ranked findings." + // Fewer than the occurrence threshold: a model can legitimately restate + // a step once or twice across tool-call cycles without looping. + const text = Array(4).fill(`${line1}${line2}`).join("") + expect(detectRepetition(text).repeating).toBe(false) + }) + + test("does not flag a repeated markdown table separator row", () => { + const row = "| ---------------------- | ---------------------- |" + const text = Array(6).fill(row).join("\n") + expect(detectRepetition(text).repeating).toBe(false) + }) + + test("does not flag a few identical code lines", () => { + const line = " const result = await fetchData(request, options, context)" + const text = Array(3).fill(line).join("\n") + expect(detectRepetition(text).repeating).toBe(false) + }) + + test("ignores short recurring fragments", () => { + const text = Array(10).fill("ok").join(" ") + expect(detectRepetition(text).repeating).toBe(false) + }) + + // A monochrome run is periodic at every period by construction — the + // easiest thing to false-trigger on if entropy is not checked. + test("does not flag a long run of the same character", () => { + expect(detectRepetition("x".repeat(500)).repeating).toBe(false) + }) + + test("does not flag a repeated horizontal rule", () => { + const text = Array(10).fill("----------------------------").join("\n") + expect(detectRepetition(text).repeating).toBe(false) + }) +}) + +describe("repetitionRecoveryMessage", () => { + test("names degeneration and attributes the looped tokens", () => { + const message = repetitionRecoveryMessage(42) + expect(message).toContain("repeating itself") + expect(message).toContain("42") + }) }) describe("shouldNoticeStall", () => { @@ -101,8 +182,13 @@ describe("shouldNoticeStall", () => { stallNoticeMs: STALL_NOTICE_MS, isProcessing: true, streamingType: null, + repeating: false, } + test("stays quiet while repeating, even if also silent by the clock", () => { + expect(shouldNoticeStall({ ...base, repeating: true })).toBe(false) + }) + test("speaks up long before the abort backstop", () => { expect(STALL_NOTICE_MS).toBeLessThan(STALL_TIMEOUT_MS) expect(shouldNoticeStall(base)).toBe(true) diff --git a/src/tui-opentui/stall-watchdog.ts b/src/tui-opentui/stall-watchdog.ts index d51178dfa..3ae4c4416 100644 --- a/src/tui-opentui/stall-watchdog.ts +++ b/src/tui-opentui/stall-watchdog.ts @@ -21,6 +21,90 @@ export type ShouldAbortForStallArgs = { readonly streamingType: "text" | "thinking" | "tool" | null } +// The captured incident looped two sentences with no line break between them +// ("...ranked findings.Confirming callId emission...") — degeneration is a +// character-level loop, not a line-level one. Splitting on "\n" misses it +// entirely, so the tail is treated as a plain string and checked for the +// smallest period it exactly repeats: the shortest span p such that the last +// several hundred characters equal p repeated. +// +// A period below this is more likely a short structural tic (indentation, a +// repeated bullet or table-cell divider) than a looping phrase. Chosen well +// under the ~140-char period of the captured incident's two-sentence cycle, +// with headroom for shorter degenerate loops (a single repeated sentence). +const REPETITION_MIN_PERIOD = 24 +// How many exact repeats of the period are required before it counts as a +// loop rather than a coincidence. Verified against real non-degenerate +// repetition: a 6-row markdown table separator (period ~51 chars, 6 exact +// repeats) and 3 identical code lines (period ~60 chars, 3 exact repeats) +// both land under this bar and are not flagged; the captured incident's +// sentence pair comfortably clears it well before the stream ends. +const REPETITION_MIN_REPEATS = 8 +// Hard ceiling on the period search regardless of buffer size, purely to cap +// worst-case work per check — token-level degeneration loops on a phrase or +// two, never on multi-paragraph spans. +const REPETITION_MAX_PERIOD_CAP = 2_000 +// A monochrome run ("x".repeat(500), a "----" rule, a wall of spaces) is +// trivially periodic at *every* period, which would otherwise make it the +// single easiest thing to false-trigger on — verified by execution against +// `thinking-reveal.test.ts`'s burst-of-"x" fixture, which tripped the guard +// before this floor existed. Requiring the repeating unit itself to contain +// this many distinct characters keeps single-character and low-variety runs +// out without weakening the sentence-level case: the captured incident's +// cycle spans two full sentences, comfortably above it. +const REPETITION_MIN_DISTINCT_CHARS = 8 + +export type RepetitionCheck = { + readonly repeating: boolean + readonly period: number | null + readonly repeats: number +} + +/** + * Length of the exact-period run ending at the last character of `text`, + * including the base period itself. `text[i] === text[i - period]` walked + * backwards from the end; stops at the first mismatch or the start of the + * string. + */ +function periodicSuffixLength(text: string, period: number): number { + let i = text.length - 1 + let j = i - period + let matched = 0 + while (j >= 0 && text[i] === text[j]) { + matched++ + i-- + j-- + } + return matched + period +} + +/** + * Whether the tail of `text` is an exact repeat of some short span at least + * `REPETITION_MIN_REPEATS` times. Pure text-in, decision-out: the caller owns + * accumulating the buffer across deltas and cycles within a turn. + * + * Periods longer than `text.length / REPETITION_MIN_REPEATS` are skipped, not + * as an arbitrary cutoff but because they cannot mathematically reach the + * occurrence threshold within the given text — a loop with a longer period + * needs a longer buffer to confirm, which is a buffer-size trade-off owned by + * the caller, not a second detection path here. + */ +export function detectRepetition(text: string): RepetitionCheck { + const maxPeriod = Math.min( + REPETITION_MAX_PERIOD_CAP, + Math.floor(text.length / REPETITION_MIN_REPEATS), + ) + for (let period = REPETITION_MIN_PERIOD; period <= maxPeriod; period++) { + const matched = periodicSuffixLength(text, period) + const repeats = matched / period + if (repeats < REPETITION_MIN_REPEATS) continue + const unit = text.slice(text.length - period) + if (new Set(unit).size < REPETITION_MIN_DISTINCT_CHARS) continue + return { repeating: true, period, repeats } + } + return { repeating: false, period: null, repeats: 0 } +} + /** * Whether silence of `thresholdMs` counts as stuck at all. Shared by the notice * and the abort so they never disagree about which runs are stalled — only @@ -50,31 +134,51 @@ export function shouldAbortForStall(args: ShouldAbortForStallArgs): boolean { export type ShouldNoticeStallArgs = ShouldAbortForStallArgs & { readonly stallNoticeMs: number + /** Whether the repetition guard currently sees a looping tail. */ + readonly repeating: boolean } /** * Returns true while the run has been silent long enough to say so but not yet * long enough to abort. False once the abort takes over, so the two never - * paint at the same time. + * paint at the same time, and false while repeating — that run is producing + * output, just not useful output, and "no response" would misdescribe it. */ export function shouldNoticeStall(args: ShouldNoticeStallArgs): boolean { + if (args.repeating) return false if (shouldAbortForStall(args)) return false return silentPastThreshold(args, args.stallNoticeMs) } -/** Shown while the run is silent; names the state and the way out. */ +/** + * Shown while nothing is arriving at all. Never fires while tokens are + * flowing — a model looping on repeated content is still producing output, + * so it is reported by `repetitionRecoveryMessage` instead, not this one. + */ export const STALL_NOTICE_MESSAGE = "no response for a while — ctrl+c to interrupt" export const STALL_RECOVERY_MESSAGE = "stopped after no response — send again to retry" +/** + * Shown once a repeated line aborts the turn. Named as degeneration, not a + * generic failure, so a retry reads as the reasonable next step rather than + * papering over a suspected hang or network fault. + */ +export function repetitionRecoveryMessage(repeatedTokens: number): string { + return `stopped after repeating itself — ~${repeatedTokens} tokens looped — send again to retry` +} + export type ApplyStallRecoveryDeps = { /** Abort the in-flight run through the session port. */ readonly abort: () => void readonly notify: (message: string) => void } -export function applyStallRecovery(deps: ApplyStallRecoveryDeps): void { +export function applyStallRecovery( + deps: ApplyStallRecoveryDeps, + message: string = STALL_RECOVERY_MESSAGE, +): void { deps.abort() - deps.notify(STALL_RECOVERY_MESSAGE) + deps.notify(message) } diff --git a/src/tui-opentui/turn-monitor.test.ts b/src/tui-opentui/turn-monitor.test.ts index 397ef9b93..dc4bca865 100644 --- a/src/tui-opentui/turn-monitor.test.ts +++ b/src/tui-opentui/turn-monitor.test.ts @@ -369,6 +369,64 @@ describe("stall watchdog", () => { }) }) +describe("repetition guard", () => { + test("aborts a looping model without waiting on the stall clock", async () => { + await withTestRenderer(async (h) => { + const t: Harness = await setup(h) + try { + t.bridge.submit("build it", "immediate") + t.port.clear() + + // The captured incident shape: the two sentences run together with + // no line break between cycles. + const line1 = + "I'll verify callId emission and remaining edges, then write the ranked findings." + const line2 = "Confirming callId emission, then writing the ranked findings." + const cycle = `${line1}${line2}` + + // Tokens keep landing every tick — a real stall would never fire here. + for (let i = 0; i < 10; i++) { + t.bridge.handle({ + type: "inference.text.delta", + data: { token: cycle }, + }) + t.advance(10) + t.tick() + } + + expect(t.port.calls).toEqual([{ op: "interrupt" }]) + expect(t.shell.statusFlash).toContain("repeating itself") + expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE) + } finally { + t.bridge.dispose() + } + }) + }) + + test("a slow but progressing turn is never killed", async () => { + await withTestRenderer(async (h) => { + const t: Harness = await setup(h) + try { + t.bridge.submit("build it", "immediate") + t.port.clear() + + for (let i = 0; i < 5; i++) { + t.bridge.handle({ + type: "inference.text.delta", + data: { token: `distinct progress update number ${i}\n` }, + }) + t.advance(500) + t.tick() + } + + expect(t.port.calls).toEqual([]) + } finally { + t.bridge.dispose() + } + }) + }) +}) + describe("reasoning settles to a summary", () => { test("a closed thinking row carries its elapsed time", async () => { await withTestRenderer(async (h) => { diff --git a/src/tui-opentui/turn-state.test.ts b/src/tui-opentui/turn-state.test.ts index 2cdc813aa..f987c20e5 100644 --- a/src/tui-opentui/turn-state.test.ts +++ b/src/tui-opentui/turn-state.test.ts @@ -9,7 +9,12 @@ import { } from "./turn-state.js" const fold = ( - events: readonly { type: string; data?: unknown; state?: string }[], + events: readonly { + type: string + data?: unknown + state?: string + text?: string + }[], startMs = 0, ) => events.reduce( @@ -184,3 +189,157 @@ describe("turn transitions", () => { expect(s.isProcessing).toBe(true) }) }) + +describe("repetition tracking", () => { + const line1 = + "I'll verify callId emission and remaining edges, then write the ranked findings." + const line2 = "Confirming callId emission, then writing the ranked findings." + // The captured incident shape: the two sentences run together with no + // separator, each delta landing as one full cycle. + const cycle = `${line1}${line2}` + + const textDelta = (text: string) => ({ + type: "inference.text.delta", + data: { token: text }, + }) + + test("varied streamed text is never flagged", () => { + const s = fold([ + { type: "inference.start" }, + textDelta("I'll check the callId path.\n"), + textDelta("Running the search now.\n"), + textDelta("Found the match.\n"), + ]) + expect(s.repeating).toBe(false) + expect(s.repeatingSinceTokenCount).toBeNull() + }) + + test("the captured incident shape (no separator between cycles) flips repeating", () => { + const deltas = Array(10) + .fill(cycle) + .map((text) => textDelta(text)) + const s = fold([{ type: "inference.start" }, ...deltas]) + expect(s.repeating).toBe(true) + expect(s.repeatingSinceTokenCount).not.toBeNull() + }) + + test("a couple of restated cycles across tool calls is not a loop", () => { + const deltas = Array(3) + .fill(cycle) + .map((text) => textDelta(text)) + const s = fold([{ type: "inference.start" }, ...deltas]) + expect(s.repeating).toBe(false) + }) + + test("a tool call ends the streaming cycle but does not un-latch a real detection", () => { + // The raw text buffer is discarded at the tool-call boundary (that is + // what keeps narration from accumulating into a false loop), but a real + // in-cycle detection that already fired must stay latched — the model + // did loop, and a coincidental tool call right after should not erase + // that fact. + const deltas = Array(10) + .fill(cycle) + .map((text) => textDelta(text)) + const looping = fold([{ type: "inference.start" }, ...deltas]) + expect(looping.repeating).toBe(true) + + const withTool = turnStateFromEvent( + looping, + { type: "tool.start", data: { call: { id: "c1", name: "grep" } } }, + 100, + ) + expect(withTool.repeating).toBe(true) + expect(withTool.streamText).toBe("") + + const afterReply = turnStateFromEvent( + withTool, + { type: "connector.reply" }, + 101, + ) + expect(afterReply.repeating).toBe(true) + }) + + test("the same block repeated every cycle, interleaved with tool calls, still trips as a loop", () => { + // The gap this closes: an unconditional per-cycle reset (no cross-cycle + // memory at all) never catches a model that loops while interleaving a + // trivial tool call between every repeat — verified against a 500-cycle, + // 88,000-character run that never flipped `repeating`. A fingerprint of + // each completed cycle, compared to the one before it, catches this + // shape within a small, bounded number of cycles instead. + const block = "xk4mQ2 loop unit that never varies at all here" + expect(block.length).toBeGreaterThanOrEqual(24) + + let state = fold([{ type: "inference.start" }]) + let clock = 1 + let trippedAtCycle = -1 + for (let cycleIndex = 0; cycleIndex < 30; cycleIndex++) { + state = turnStateFromEvent(state, textDelta(block), ++clock) + state = turnStateFromEvent( + state, + { + type: "tool.start", + data: { call: { id: `c${cycleIndex}`, name: "noop" } }, + }, + ++clock, + ) + state = turnStateFromEvent(state, { type: "connector.reply" }, ++clock) + state = turnStateFromEvent( + state, + { type: "tool.done", data: { result: { callId: `c${cycleIndex}` } } }, + ++clock, + ) + if (trippedAtCycle === -1 && state.repeating) trippedAtCycle = cycleIndex + } + expect(state.repeating).toBe(true) + expect(trippedAtCycle).toBeGreaterThan(-1) + expect(trippedAtCycle).toBeLessThan(30) + }) + + test("a short narration line repeated before each of nine tool calls is not a loop", () => { + // Verified false positive (CL-5577): "Let me check the next file now." + // fed in 4-char chunks before nine separate tool calls, interleaved with + // tool.start/connector.reply/tool.done, must not abort the turn. Nothing + // about saying a similar short thing before each of several tool calls + // in one turn is degenerate. + const narration = "Let me check the next file now." + const chunks: string[] = [] + for (let i = 0; i < narration.length; i += 4) { + chunks.push(narration.slice(i, i + 4)) + } + + let state = fold([{ type: "inference.start" }]) + let clock = 1 + for (let cycleIndex = 0; cycleIndex < 12; cycleIndex++) { + for (const chunk of chunks) { + state = turnStateFromEvent(state, textDelta(chunk), ++clock) + } + state = turnStateFromEvent( + state, + { + type: "tool.start", + data: { call: { id: `c${cycleIndex}`, name: "read_file" } }, + }, + ++clock, + ) + state = turnStateFromEvent(state, { type: "connector.reply" }, ++clock) + state = turnStateFromEvent( + state, + { type: "tool.done", data: { result: { callId: `c${cycleIndex}` } } }, + ++clock, + ) + expect(state.repeating).toBe(false) + } + expect(state.repeating).toBe(false) + }) + + test("a fresh submit clears the repetition state", () => { + const deltas = Array(10) + .fill(cycle) + .map((text) => textDelta(text)) + const looping = fold([{ type: "inference.start" }, ...deltas]) + const restarted = turnStateOnSubmit(looping, 200) + expect(restarted.repeating).toBe(false) + expect(restarted.repeatingSinceTokenCount).toBeNull() + expect(restarted.streamText).toBe("") + }) +}) diff --git a/src/tui-opentui/turn-state.ts b/src/tui-opentui/turn-state.ts index 2f681d23c..d0cb54cc8 100644 --- a/src/tui-opentui/turn-state.ts +++ b/src/tui-opentui/turn-state.ts @@ -11,8 +11,63 @@ import { type } from "arktype" +import { detectRepetition } from "./stall-watchdog.js" import type { TurnStatus } from "./session-chrome.js" +// Bound on the accumulated stream text kept for repetition checks. Comfortably +// larger than the periods `detectRepetition` can confirm, so trimming never +// drops content the check still needs. +const STREAM_TEXT_BUFFER_CHARS = 8_000 + +// `detectRepetition` walks a character-level period search; cheap per call, +// but the reactor loop can emit a delta per token, and running it on every +// single one makes it the hottest thing in that loop for no benefit — a +// repeating tail does not appear or disappear between two three-character +// tokens. Checking once per chunk of newly streamed text instead keeps the +// cost proportional to output, not token count. +const REPETITION_CHECK_INTERVAL_CHARS = 40 + +// Cycles shorter than this are skipped when updating the cross-cycle streak: +// a bare tool call with no preceding text, or a one-word aside, is too little +// signal to compare — matching by coincidence is common at this length, and +// skipping neither breaks nor extends a streak already in progress. +const CYCLE_FINGERPRINT_MIN_CHARS = 24 + +// How many consecutive cycles must fingerprint identically before it counts +// as a loop rather than ordinary phrasing. The fingerprint covers the whole +// cycle's text, so any variation at all — a changing filename, index, or +// detail ("Editing src/module_47.ts next.") produces a different hash and +// never advances the streak, no matter how many cycles run. That is what +// makes this bar tolerable at a bare-number glance: it only ever governs +// content that is byte-for-byte invariant, cycle after cycle, which ordinary +// narration is not. The verified false positive (CL-5577) is a model saying +// the exact same short line before each of 9-12 separate tool calls in one +// turn — that must not abort, so the bar sits above that range with +// headroom. Set well below the reported repro (an unvarying 46-char block +// repeated every cycle for 500 cycles, which the unconditional-reset version +// never caught at all): at this bar the streak still trips a small fraction +// of the way in, a few thousand characters and under two dozen tool calls, +// not after 500 and 88,000 characters. The remaining exposure is narrow and +// explicit: an exact, invariant line of at least `CYCLE_FINGERPRINT_MIN_CHARS` +// chars repeated with zero variation for this many cycles running straight +// through tool calls — contentless boilerplate, not narration. +const CYCLE_REPETITION_MIN_CONSECUTIVE = 20 + +/** + * Cheap 32-bit fingerprint (FNV-1a) of one completed cycle's text, so the + * cross-cycle streak only has to remember a short string per turn rather than + * retain raw text across cycles — the retained text is exactly what caused + * the cross-cycle false positive this replaces. + */ +function cycleFingerprint(text: string): string { + let hash = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16) +} + export type QuotaWait = { readonly retryAfterMs: number readonly retryAt: number @@ -41,6 +96,45 @@ export type TurnState = { * so the settle decision needs the outstanding ids, not just the last name. */ readonly activeToolCalls: readonly string[] + /** + * Tail of the text/thinking output streamed in the current uninterrupted + * streaming cycle. A tool call ends the cycle and clears it: a model + * narrating a similar short line before each of several tool calls is + * ordinary and must not accumulate into an apparent loop, whereas a + * genuinely degenerate model repeats within one unbroken stream. Bounded to + * `STREAM_TEXT_BUFFER_CHARS`; feeds `detectRepetition`, nothing else. + */ + readonly streamText: string + /** + * Total characters streamed this turn, uncapped — unlike `streamText.length` + * this keeps climbing after the buffer fills, which is what lets the + * throttle below tell "40 more chars arrived" from "the buffer is full." + */ + readonly streamCharsSeen: number + /** `streamCharsSeen` as of the last `detectRepetition` call. */ + readonly repetitionCheckedAt: number + /** Result of the most recent `detectRepetition` check on `streamText`. */ + readonly repeating: boolean + /** + * `streamTokenCount` at the moment repetition was first observed this turn. + * Latched, not recomputed, so the abort can report tokens spent looping + * rather than the whole turn's count. + */ + readonly repeatingSinceTokenCount: number | null + /** + * Fingerprint of the most recently completed streaming cycle (set at each + * tool-call boundary), used only to compare against the next cycle's + * fingerprint. Not the raw text — carrying that across cycles is what + * caused repeats to accumulate into a false positive across tool calls. + */ + readonly cycleFingerprint: string | null + /** + * Consecutive completed cycles whose fingerprint matched the one before it. + * A model repeating the same block every cycle, with a tool call in + * between each, builds this streak even though no single cycle's text ever + * gets long enough to trip `detectRepetition` on its own. + */ + readonly consecutiveMatchingCycles: number } export function initialTurnState(nowMs: number): TurnState { @@ -54,6 +148,13 @@ export function initialTurnState(nowMs: number): TurnState { lastActivityAt: nowMs, quota: null, activeToolCalls: [], + streamText: "", + streamCharsSeen: 0, + repetitionCheckedAt: 0, + repeating: false, + repeatingSinceTokenCount: null, + cycleFingerprint: null, + consecutiveMatchingCycles: 0, } } @@ -69,6 +170,13 @@ export function turnStateOnSubmit(state: TurnState, nowMs: number): TurnState { streamTokenCount: 0, lastActivityAt: nowMs, activeToolCalls: [], + streamText: "", + streamCharsSeen: 0, + repetitionCheckedAt: 0, + repeating: false, + repeatingSinceTokenCount: null, + cycleFingerprint: null, + consecutiveMatchingCycles: 0, } } @@ -93,6 +201,20 @@ const inferenceErrorData = type({ }, }) +const tokenData = type({ "token?": "string" }) + +/** + * Text carried by a delta event. Reactor-shaped deltas carry it as + * `data.token`; canonical bridge deltas carry it as a top-level `text`. + */ +function deltaText(event: { readonly data?: unknown; readonly text?: string }): string { + const parsed = tokenData(event.data) + if (!(parsed instanceof type.errors) && parsed.token !== undefined) { + return parsed.token + } + return event.text ?? "" +} + const namedCallData = type({ "name?": "string" }) const toolStartData = type({ call: { "name?": "string" }, @@ -178,30 +300,93 @@ const streaming = ( state: TurnState, kind: "text" | "thinking", nowMs: number, -): TurnState => ({ - ...state, - status: state.status === "blocked" ? "blocked" : "running", - isProcessing: true, - awaitingResponse: false, - streamingType: kind, - streamTokenCount: - kind === "text" ? state.streamTokenCount + 1 : state.streamTokenCount, - lastActivityAt: nowMs, -}) + text: string, +): TurnState => { + const streamTokenCount = + kind === "text" ? state.streamTokenCount + 1 : state.streamTokenCount + const streamText = `${state.streamText}${text}`.slice( + -STREAM_TEXT_BUFFER_CHARS, + ) + const streamCharsSeen = state.streamCharsSeen + text.length + const due = + streamCharsSeen - state.repetitionCheckedAt >= REPETITION_CHECK_INTERVAL_CHARS + // Once true, stays true for the rest of the turn — a fresh cycle's buffer + // starts empty (see `runningTool`) and would otherwise read back false on + // the next check, un-latching a real detection the moment a tool call + // interrupts the stream. + const repeating = + state.repeating || (due && detectRepetition(streamText).repeating) + return { + ...state, + status: state.status === "blocked" ? "blocked" : "running", + isProcessing: true, + awaitingResponse: false, + streamingType: kind, + streamTokenCount, + lastActivityAt: nowMs, + streamText, + streamCharsSeen, + repetitionCheckedAt: due ? streamCharsSeen : state.repetitionCheckedAt, + repeating, + repeatingSinceTokenCount: + repeating && state.repeatingSinceTokenCount === null + ? streamTokenCount + : state.repeatingSinceTokenCount, + } +} +// A tool call ends the current streaming cycle. The raw text buffer is +// discarded here, rather than only on a fresh turn, so repeats never +// accumulate across `connector.reply` boundaries — the mechanism that turned +// nine separate narration lines ("Let me check the next file now.") into one +// apparent loop and killed an ordinary turn mid-flight. But discarding the +// buffer outright would also erase a genuine loop that interleaves a tool +// call between every repeat of the same block, so a fingerprint of the +// completed cycle is kept and compared against the next one: several +// consecutive cycles fingerprinting alike is what that shape of loop looks +// like, and nine different narration lines never do. const runningTool = ( state: TurnState, name: string | null, nowMs: number, -): TurnState => ({ - ...state, - status: state.status === "blocked" ? "blocked" : "running", - isProcessing: true, - awaitingResponse: false, - streamingType: "tool", - currentToolName: name ?? state.currentToolName, - lastActivityAt: nowMs, -}) +): TurnState => { + const cycleText = state.streamText + const longEnoughToCompare = cycleText.length >= CYCLE_FINGERPRINT_MIN_CHARS + const fingerprint = longEnoughToCompare + ? cycleFingerprint(cycleText) + : null + const matchedPrevious = + longEnoughToCompare && + state.cycleFingerprint !== null && + fingerprint === state.cycleFingerprint + const consecutiveMatchingCycles = matchedPrevious + ? state.consecutiveMatchingCycles + 1 + : longEnoughToCompare + ? 1 + : state.consecutiveMatchingCycles + const repeating = + state.repeating || consecutiveMatchingCycles >= CYCLE_REPETITION_MIN_CONSECUTIVE + + return { + ...state, + status: state.status === "blocked" ? "blocked" : "running", + isProcessing: true, + awaitingResponse: false, + streamingType: "tool", + currentToolName: name ?? state.currentToolName, + lastActivityAt: nowMs, + streamText: "", + streamCharsSeen: 0, + repetitionCheckedAt: 0, + repeating, + repeatingSinceTokenCount: + repeating && state.repeatingSinceTokenCount === null + ? state.streamTokenCount + : state.repeatingSinceTokenCount, + cycleFingerprint: longEnoughToCompare ? fingerprint : state.cycleFingerprint, + consecutiveMatchingCycles, + } +} /** * Fold one inbound event (reactor-shaped or canonical bridge-shaped) into the @@ -215,6 +400,7 @@ export function turnStateFromEvent( /** Canonical bridge shapes carry these instead of `data`. */ readonly state?: string readonly name?: string + readonly text?: string }, nowMs: number, ): TurnState { @@ -235,10 +421,10 @@ export function turnStateFromEvent( case "inference.text.delta": case "assistant.delta": - return streaming(state, "text", nowMs) + return streaming(state, "text", nowMs, deltaText(event)) case "inference.thinking.delta": - return streaming(state, "thinking", nowMs) + return streaming(state, "thinking", nowMs, deltaText(event)) case "inference.tool_call.delta": return runningTool(state, toolName(event.data), nowMs)