Skip to content

Commit 9284bbe

Browse files
committed
Bound the explicit think-open hold: commit as reasoning past EXPLICIT_OPEN_HOLD_CHARS so paired traces stream live (review feedback on #1173)
1 parent 0fead1c commit 9284bbe

2 files changed

Lines changed: 93 additions & 34 deletions

File tree

packages/agent-runtime/src/util/__tests__/think-tag-stream.test.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'
22

33
import {
44
historyLeaksThinkTags,
5+
EXPLICIT_OPEN_HOLD_CHARS,
56
IMPLICIT_OPEN_BUDGET_CHARS,
67
stripThinkScaffolding,
78
ThinkTagStream,
@@ -91,7 +92,36 @@ describe('ThinkTagStream — paired tags', () => {
9192
{ type: 'text', text: 'Here is the answer.' },
9293
])
9394
})
95+
it('commits the hold as reasoning past the bound and streams the rest live', () => {
96+
// A genuine long trace (R1-style) must not buffer until its close: past
97+
// the hold bound the paired-block reading wins and the block streams.
98+
const stream = new ThinkTagStream()
99+
const long = 'x'.repeat(EXPLICIT_OPEN_HOLD_CHARS)
100+
expect(stream.push(`<think>${long}`)).toEqual([
101+
{ type: 'reasoning', text: long },
102+
])
103+
expect(stream.push(' still going')).toEqual([
104+
{ type: 'reasoning', text: ' still going' },
105+
])
106+
// Committed: a step end releases only the trailing partial, as reasoning.
107+
expect(stream.push('</thi')).toEqual([])
108+
expect(stream.flush()).toEqual([{ type: 'reasoning', text: '</thi' }])
109+
})
94110

111+
it('keeps the answer as text when an unclosed explicit open stays under the bound', () => {
112+
const out = run([`<think>${'y'.repeat(EXPLICIT_OPEN_HOLD_CHARS - 1)}`])
113+
expect(joined(out, 'text')).toBe('y'.repeat(EXPLICIT_OPEN_HOLD_CHARS - 1))
114+
expect(joined(out, 'reasoning')).toBe('')
115+
})
116+
117+
it('does not commit an implicit head past the explicit bound; text release wins', () => {
118+
const stream = new ThinkTagStream({ implicitOpen: true })
119+
const long = 'x'.repeat(IMPLICIT_OPEN_BUDGET_CHARS)
120+
expect(stream.push(long)).toEqual([{ type: 'text', text: long }])
121+
expect(stream.push('</think>tail')).toEqual([
122+
{ type: 'text', text: 'tail' },
123+
])
124+
})
95125
it('releases an explicit open on a native reasoning chunk as text', () => {
96126
const stream = new ThinkTagStream()
97127
expect(stream.push('A <think> quoted open')).toEqual([
@@ -105,7 +135,6 @@ describe('ThinkTagStream — paired tags', () => {
105135
])
106136
})
107137
})
108-
109138
describe('ThinkTagStream — orphan close, not armed', () => {
110139
// The default for every non-leaking model: the prose is not reclassified
111140
// (it already streamed), but the bare marker must never reach a transcript.
@@ -126,7 +155,10 @@ describe('ThinkTagStream — orphan close, not armed', () => {
126155
describe('ThinkTagStream — orphan close, armed', () => {
127156
it('reclassifies the head as reasoning once the marker lands', () => {
128157
const out = run(
129-
['Ключевая зацепка: the bundle knows the type.', 'Do that.</think>Real answer.'],
158+
[
159+
'Ключевая зацепка: the bundle knows the type.',
160+
'Do that.</think>Real answer.',
161+
],
130162
{ implicitOpen: true },
131163
)
132164
expect(out).toEqual([
@@ -173,7 +205,9 @@ describe('ThinkTagStream — orphan close, armed', () => {
173205
{ type: 'text', text: ' and more' },
174206
])
175207
// Disarmed: a later marker is stripped, not treated as a close.
176-
expect(stream.push('</think>tail')).toEqual([{ type: 'text', text: 'tail' }])
208+
expect(stream.push('</think>tail')).toEqual([
209+
{ type: 'text', text: 'tail' },
210+
])
177211
})
178212

179213
it('disarms on a native reasoning chunk and releases the head as text', () => {
@@ -223,7 +257,10 @@ describe('historyLeaksThinkTags', () => {
223257
expect(
224258
historyLeaksThinkTags([
225259
{ role: 'user', content: [{ type: 'text', text: 'why </think>?' }] },
226-
{ role: 'assistant', content: [{ type: 'reasoning', text: '</think>' }] },
260+
{
261+
role: 'assistant',
262+
content: [{ type: 'reasoning', text: '</think>' }],
263+
},
227264
]),
228265
).toBe(false)
229266
})
@@ -238,7 +275,9 @@ describe('stripThinkScaffolding', () => {
238275

239276
it('leaves surrounding whitespace alone, unlike stripThinkTags', () => {
240277
expect(stripThinkScaffolding(' spaced ')).toBe(' spaced ')
241-
expect(stripThinkScaffolding('a\n\n<think>x</think>\n\nb')).toBe('a\n\n\n\nb')
278+
expect(stripThinkScaffolding('a\n\n<think>x</think>\n\nb')).toBe(
279+
'a\n\n\n\nb',
280+
)
242281
})
243282
})
244283

packages/agent-runtime/src/util/think-tag-stream.ts

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@
2727
* mention the tag, a quoted template, a lane with a broken chat template.
2828
* The two are indistinguishable while the deltas arrive, so everything
2929
* after the open is HELD rather than emitted (same mechanism as rule 3).
30-
* A close settles the hold as reasoning; a step that ends without one
31-
* releases it as text, so an answer is delayed but never swallowed.
32-
* (History still stores the raw text either way — this is a
33-
* display/reclassification concern, not context loss.)
30+
* A close settles the hold as reasoning. Past
31+
* {@link EXPLICIT_OPEN_HOLD_CHARS} without one, the hold COMMITS as
32+
* reasoning and the rest of the block streams live — a genuine chain of
33+
* thought runs arbitrarily long, and buffering one until its close would
34+
* freeze the thinking box for the whole trace. A step that ends still
35+
* undecided (never closed, never crossed the bound) releases the hold as
36+
* text, so an answer is delayed but never swallowed.
3437
* 3. An orphan `</think>` with no open tag — the DeepSeek shape above, where
3538
* the open tag was consumed by the chat template's prefill. The text
3639
* BEFORE it is reasoning, but by the time the marker arrives that text has
@@ -68,13 +71,22 @@ export interface ThinkTagStreamOptions {
6871
}
6972

7073
/**
71-
* How much leading content to hold while waiting for an orphan `</think>`.
74+
* How much content an open block may hold while its classification is
75+
* undecided, before the hold gives up.
7276
*
73-
* A leaked chain of thought runs well past this, so the cap is not there to
74-
* fit one — it bounds the wrong case. If the marker has not arrived by here
75-
* the step is answering, not thinking, and the buffer is released as text.
77+
* For the implicit head ({@link ThinkTagStreamOptions.implicitOpen}) the cap
78+
* bounds the wrong case: a leaked chain of thought runs well past it, so if
79+
* the orphan `</think>` has not arrived by here the step is answering, not
80+
* thinking, and the buffer is released as text.
81+
*
82+
* For an explicit `<think>` open the same magnitude is a latency cap, not a
83+
* size estimate: past it the hold commits as reasoning ({@link
84+
* EXPLICIT_OPEN_HOLD_CHARS}) rather than releasing as text, because a genuine
85+
* block legitimately runs long — the wrong case to bound is the waiting, not
86+
* the trace.
7687
*/
7788
export const IMPLICIT_OPEN_BUDGET_CHARS = 4000
89+
export const EXPLICIT_OPEN_HOLD_CHARS = IMPLICIT_OPEN_BUDGET_CHARS
7890

7991
/**
8092
* Remove think scaffolding from a fragment, leaving everything else — including
@@ -167,8 +179,9 @@ export class ThinkTagStream {
167179
private inThinkBlock: boolean
168180
/** True while `held` is waiting for the close that settles its
169181
* classification. Armed by construction (implicitOpen) and by every open
170-
* tag; cleared by the close ({@link confirmOpenHold}) or by giving up
171-
* ({@link abandonOpenHold}) — budget, a native reasoning chunk, or flush. */
182+
* tag; settled by the close or by the explicit hold bound
183+
* ({@link confirmOpenHold}), or given up ({@link abandonOpenHold}) —
184+
* budget, a native reasoning chunk, or flush. */
172185
private holdingForOpen: boolean
173186

174187
constructor(options: ThinkTagStreamOptions = {}) {
@@ -240,20 +253,26 @@ export class ThinkTagStream {
240253
}
241254

242255
/** Emit everything withheld. A partial tag that never completed was always
243-
* just text, and content held for a close that never came is the answer —
244-
* releasing both here is what makes the hold lossless. An unclosed open is
245-
* treated exactly like an orphan close: the marker is scaffolding, the
246-
* text around it is the answer. */
256+
* just text — except inside a block that committed as reasoning, where a
257+
* truncated close belongs with the trace it would have ended. Content
258+
* held for a close that never came is the answer: releasing it here is
259+
* what makes the hold lossless. An unclosed open is treated exactly like
260+
* an orphan close — the marker is scaffolding, the text around it is the
261+
* answer. */
247262
flush(): ThinkStreamSegment[] {
248263
const segments: ThinkStreamSegment[] = []
249264
if (this.holdingForOpen) segments.push(...this.abandonOpenHold())
250265
const trailing = this.partial
251266
this.partial = ''
252-
if (trailing) this.addText(segments, trailing)
267+
if (trailing) {
268+
if (this.inThinkBlock) this.addReasoning(segments, trailing)
269+
else this.addText(segments, trailing)
270+
}
253271
return segments
254272
}
255273

256-
/** The close arrived: what was held was reasoning after all. */
274+
/** The close arrived — or the hold bound crossed without one: what was
275+
* held was reasoning after all, and the rest of the block streams live. */
257276
private confirmOpenHold(segments: ThinkStreamSegment[]): void {
258277
if (!this.holdingForOpen) return
259278
this.holdingForOpen = false
@@ -273,10 +292,7 @@ export class ThinkTagStream {
273292
return held ? [{ type: 'text', text: held }] : []
274293
}
275294

276-
private addReasoning(
277-
segments: ThinkStreamSegment[],
278-
text: string,
279-
): void {
295+
private addReasoning(segments: ThinkStreamSegment[], text: string): void {
280296
// A nested/duplicated open tag inside a block is scaffolding, never thought.
281297
const cleaned = text.split(OPEN_TAG).join('')
282298
if (!cleaned) return
@@ -285,17 +301,21 @@ export class ThinkTagStream {
285301
return
286302
}
287303
// Undecided: reasoning only if a close confirms it, so hold rather than
288-
// send. The budget bounds only the implicit-head speculation — a held
289-
// chain of thought from a leaking lane runs well past it, so past the
290-
// budget the step is answering, not thinking, and the hold is released as
291-
// text. An explicit open gets no budget: a genuine think block can
292-
// legitimately run long, and only its close (or flush) settles it.
304+
// send. Two give-up points settle it, in opposite directions. The
305+
// implicit-head budget releases as TEXT — a held chain of thought from a
306+
// leaking lane runs well past it, so past the budget the step is
307+
// answering, not thinking. The explicit-open hold COMMITS as reasoning —
308+
// a genuine block runs arbitrarily long, and buffering one until its
309+
// close would freeze the thinking box for the whole trace, so past the
310+
// bound the paired-block reading wins and the rest streams live.
293311
this.held += cleaned
294-
if (
295-
this.implicitOpen &&
296-
this.held.length >= IMPLICIT_OPEN_BUDGET_CHARS
297-
) {
312+
if (this.implicitOpen && this.held.length >= IMPLICIT_OPEN_BUDGET_CHARS) {
298313
segments.push(...this.abandonOpenHold())
314+
} else if (
315+
!this.implicitOpen &&
316+
this.held.length >= EXPLICIT_OPEN_HOLD_CHARS
317+
) {
318+
this.confirmOpenHold(segments)
299319
}
300320
}
301321

0 commit comments

Comments
 (0)