Skip to content

Commit 95bbd86

Browse files
committed
Scope the repetition guard's buffer to one streaming cycle
A tool call ended a streaming cycle but left the repetition buffer intact, so several short narration lines said before separate tool calls in one turn concatenated into an apparent loop and aborted an otherwise ordinary turn. A genuinely degenerate model repeats within one unbroken stream; narration between tool calls does not, so the buffer now clears whenever a tool call begins.
1 parent 0f1ae2f commit 95bbd86

2 files changed

Lines changed: 60 additions & 5 deletions

File tree

src/tui-opentui/turn-state.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,10 @@ describe("repetition tracking", () => {
231231
expect(s.repeating).toBe(false)
232232
})
233233

234-
test("repetition tracked across a tool cycle survives connector.reply with tools outstanding", () => {
234+
test("a tool call ends the streaming cycle and clears the repetition buffer", () => {
235+
// Repeats within one unbroken stream are a real loop; a tool call
236+
// interrupting the stream is not part of that cycle, so it must not
237+
// carry the accumulated repetition state into the next one.
235238
const deltas = Array(10)
236239
.fill(cycle)
237240
.map((text) => textDelta(text))
@@ -240,13 +243,52 @@ describe("repetition tracking", () => {
240243
{ type: "tool.start", data: { call: { id: "c1", name: "grep" } } },
241244
100,
242245
)
246+
expect(withTool.repeating).toBe(false)
247+
expect(withTool.streamText).toBe("")
248+
243249
const afterReply = turnStateFromEvent(
244250
withTool,
245251
{ type: "connector.reply" },
246252
101,
247253
)
248-
expect(afterReply.repeating).toBe(true)
249-
expect(afterReply.streamText.length).toBeGreaterThan(0)
254+
expect(afterReply.repeating).toBe(false)
255+
})
256+
257+
test("a short narration line repeated before each of nine tool calls is not a loop", () => {
258+
// Verified false positive (CL-5577): "Let me check the next file now."
259+
// fed in 4-char chunks before nine separate tool calls, interleaved with
260+
// tool.start/connector.reply/tool.done, must not abort the turn. Nothing
261+
// about saying a similar short thing before each of several tool calls
262+
// in one turn is degenerate.
263+
const narration = "Let me check the next file now."
264+
const chunks: string[] = []
265+
for (let i = 0; i < narration.length; i += 4) {
266+
chunks.push(narration.slice(i, i + 4))
267+
}
268+
269+
let state = fold([{ type: "inference.start" }])
270+
let clock = 1
271+
for (let cycleIndex = 0; cycleIndex < 12; cycleIndex++) {
272+
for (const chunk of chunks) {
273+
state = turnStateFromEvent(state, textDelta(chunk), ++clock)
274+
}
275+
state = turnStateFromEvent(
276+
state,
277+
{
278+
type: "tool.start",
279+
data: { call: { id: `c${cycleIndex}`, name: "read_file" } },
280+
},
281+
++clock,
282+
)
283+
state = turnStateFromEvent(state, { type: "connector.reply" }, ++clock)
284+
state = turnStateFromEvent(
285+
state,
286+
{ type: "tool.done", data: { result: { callId: `c${cycleIndex}` } } },
287+
++clock,
288+
)
289+
expect(state.repeating).toBe(false)
290+
}
291+
expect(state.repeating).toBe(false)
250292
})
251293

252294
test("a fresh submit clears the repetition state", () => {

src/tui-opentui/turn-state.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ export type TurnState = {
5656
*/
5757
readonly activeToolCalls: readonly string[]
5858
/**
59-
* Tail of the text/thinking output streamed this turn, across cycles —
60-
* `connector.reply` with tools outstanding does not clear it. Bounded to
59+
* Tail of the text/thinking output streamed in the current uninterrupted
60+
* streaming cycle. A tool call ends the cycle and clears it: a model
61+
* narrating a similar short line before each of several tool calls is
62+
* ordinary and must not accumulate into an apparent loop, whereas a
63+
* genuinely degenerate model repeats within one unbroken stream. Bounded to
6164
* `STREAM_TEXT_BUFFER_CHARS`; feeds `detectRepetition`, nothing else.
6265
*/
6366
readonly streamText: string
@@ -268,6 +271,11 @@ const streaming = (
268271
}
269272
}
270273

274+
// A tool call ends the current streaming cycle. Clearing the repetition
275+
// buffer here, rather than only on a fresh turn, is what keeps repeats from
276+
// accumulating across `connector.reply` boundaries — the mechanism that
277+
// turned nine separate narration lines ("Let me check the next file now.")
278+
// into one apparent loop and killed an ordinary turn mid-flight.
271279
const runningTool = (
272280
state: TurnState,
273281
name: string | null,
@@ -280,6 +288,11 @@ const runningTool = (
280288
streamingType: "tool",
281289
currentToolName: name ?? state.currentToolName,
282290
lastActivityAt: nowMs,
291+
streamText: "",
292+
streamCharsSeen: 0,
293+
repetitionCheckedAt: 0,
294+
repeating: false,
295+
repeatingSinceTokenCount: null,
283296
})
284297

285298
/**

0 commit comments

Comments
 (0)