src/response-classifier.js
Rama master · réplica de lectura del repositorio original.
// Response-content classifier (Thinking-core item 2, applied in item 1).
//
// Purpose: decide which parts of a streamed answer are *reasoning* vs *actionable
// text*, so misrouted content can be corrected at the egress and self-reinforcing
// loops can be broken.
//
// Degradation addressed: the model sometimes emits its reasoning through the CONTENT
// channel wrapped in its native markers. The client stores that as visible assistant
// text and resends it next turn, re-priming more reasoning-as-text — the loop.
//
// This module detects the markers in the content stream and lets the egress reroute
// the marked spans to the thinking channel (which clients do not resend), breaking the
// loop. Scope: a LEADING reasoning block only (marker at the very start, optional
// whitespace before it). No synthetic signatures, no router reliance, no guesses about
// unmarked or mid-answer content. Handles markers split across stream deltas.
const THINK_OPEN = '<' + 'think' + '>';
const THINK_CLOSE = '<' + '/' + 'think' + '>';
const MAX_PENDING = 32000; // hold ceiling for an unterminated think span
const MAX_LEAD = 8192; // undecided-hold ceiling before committing to text
export class ThinkTextClassifier {
constructor() {
this.pending = '';
this.mode = 'undecided'; // undecided | text | think
}
// Feed a content delta; returns { text, thinking } — slices to emit on each channel
// right now ('' when nothing is due).
//
// think/undecided share one explicit loop so a chain of blocks inside a single delta
// is processed iteratively. The original mutual recursion (_feedThink -> _feedUndecided
// -> _feedThink -> ...) grew one stack frame per stacked block and threw a RangeError
// at a few thousand of them; MAX_PENDING does not bound that, since it only caps a
// single unterminated span, not a chain of closed ones.
feed(delta) {
if (!delta) return { text: '', thinking: '' };
if (this.mode === 'text') return { text: delta, thinking: '' };
let rest = delta;
let text = '';
let thinking = '';
while (true) {
// Each helper scans `this.pending`, so the remainder of a closed block (or the
// original delta) is folded in at the top of every iteration.
this.pending += rest;
rest = '';
if (this.mode === 'think') {
const s = this._scanThink();
if (s.text !== undefined) return { text: s.text, thinking }; // MAX_PENDING dump
if (s.hold) return { text, thinking }; // buffer until the close marker proves it
thinking += s.span; // closed block -> reroute its reasoning
rest = s.rest; // re-scan the remainder this same delta
continue;
}
const u = this._scanUndecided();
if (u.toThink) continue; // leading marker -> think mode, same delta
if (u.text !== undefined) {
return { text: text + u.text, thinking }; // committed as text (inline / overflow)
}
return { text, thinking }; // hold: may still grow into a marker
}
}
// Scan an undecided buffer for a decision. Returns either a completed result (the
// caller returns it), `{ toThink: true }` to hand the remainder after a leading
// marker to think mode, or `{ toThink: false }` to hold. Never recurses.
_scanUndecided() {
const oi = this.pending.indexOf(THINK_OPEN);
if (oi >= 0) {
if (this.pending.slice(0, oi).trim() === '') {
// Leading marker -> enter think mode; drop whitespace-only prefix.
this.mode = 'think';
this.pending = this.pending.slice(oi + THINK_OPEN.length);
return { toThink: true }; // process the remainder of this same delta
}
// Marker present but real text precedes it -> inline, not a leak.
return this._commitText();
}
// Enough held without a decision -> it is plain text; commit. This check
// MUST run after the marker scan (not before): MAX_LEAD is a cap on undecided
// *content* that proves it is text, not a budget that lets an early overflow
// dump a leading think block to the text channel before the scan ever runs.
if (this.pending.length > MAX_LEAD) return this._commitText();
// No full marker yet. Could the pending still grow into one?
const core = this.pending.replace(/^\s+/, '');
if (core.length === 0) return { toThink: false }; // only whitespace so far
if (THINK_OPEN.startsWith(core)) return { toThink: false }; // partial marker
return this._commitText(); // definitively not a leading marker
}
_commitText() {
const out = this.pending;
this.pending = '';
this.mode = 'text';
return { text: out, thinking: '' };
}
// Scan a think-mode buffer for a closing marker. Returns `{ hold: true }` to keep
// buffering, `{ text, thinking }` for the MAX_PENDING dump-as-text escape, or
// `{ span, rest }` for a closed block — the caller re-scans `rest` in the same
// iteration, so a chain of blocks never nests another call.
_scanThink() {
const ci = this.pending.indexOf(THINK_CLOSE);
if (ci >= 0) {
const span = this.pending.slice(0, ci);
const rest = this.pending.slice(ci + THINK_CLOSE.length);
this.pending = '';
this.mode = 'undecided'; // may catch a following block; normal text commits
return { span, rest };
}
if (this.pending.length > MAX_PENDING) {
// Pathological unterminated span -> deliver as text (visible beats dropped,
// and loop-break value is gone at this size anyway).
const dump = this.pending;
this.pending = '';
this.mode = 'text';
return { text: dump, thinking: '' };
}
return { hold: true }; // buffer until the close marker proves it
}
// Stream end: flush whatever is held. Undecided content is text; an unterminated
// think span is NOT rerouted (it never proved it was reasoning) — delivered as text.
flush() {
const out = this.pending;
this.pending = '';
this.mode = 'text';
return out;
}
}
export const THINK_MARKERS = { open: THINK_OPEN, close: THINK_CLOSE };