Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ LS_PORT=42100
# regeneration, system-prompt drift, and response truncation; parallel dialogs on
# the same key stay independent. In-memory only (no persistence across restarts).
# DEVIN_CONNECT_SESSION_REUSE=0
# --- Think-text reroute (Thinking-core item 1, loop break) ---
# When the model emits a LEADING think-tagged span on the CONTENT channel, reroute it
# to the thinking channel so clients do not store it as visible assistant text and
# resend it into a self-reinforcing loop. Anthropic Messages egress. Default 0 (off).
# DEVIN_CONNECT_THINKTEXT_REROUTE=0
# State TTL before a dormant dialog's session_id is forgotten. Default 1800000 (30 min).
# DEVIN_CONNECT_SESSION_TTL_MS=1800000
# Max tracked dialogs (LRU-evicted). Default 500.
Expand Down
44 changes: 43 additions & 1 deletion src/devin-connect-openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ import { log } from './config.js';
import { systemFingerprint } from './system-fingerprint.js';
import { applyStop, StopSequenceGate } from './stop-sequences.js';
import { normalizeToolCallArgs, recordArgRepair } from './handlers/cline-compat.js';
import { ThinkTextClassifier } from './response-classifier.js';

// Gate for the leading think-tag reroute (Item 1, loop break). The classifier
// lives HERE, in the connect layer, so the DEVIN_CONNECT_ prefix is accurate:
// only the connect backend's streams are reclassified. Default OFF — the
// reroute is a behavior change and must be opted into per deployment.
function thinkTextRerouteEnabled() {
return String(process.env.DEVIN_CONNECT_THINKTEXT_REROUTE || '') === '1';
}

// Apply the Cline compat tool-arg shim when active: normalize an arguments
// string @ai-sdk/openai-compatible would reject (empty / whitespace / non-JSON)
Expand Down Expand Up @@ -213,8 +222,30 @@ async function* streamChatWithEmptyRetry(params, { env = process.env } = {}) {
let sawReasoning = false;
let sawReasoningText = '';
let finishEv = null;
// Item 1 (loop break): leading think-tagged CONTENT is reclassified into the
// reasoning channel HERE, at the stream-event level, BEFORE the rescue
// decision below. A whole turn of ` thinking…` therefore stays a
// reasoning-only stream: sawText stays false, so the #238 rescue fires
// naturally — the reroute and the rescue combine, neither bypasses the other.
// (Known limitation: only the ` thinking`…` dialect is recognized; Kimi K2's
// family uses `◁think▷…◁/think▷` — extending the classifier to that dialect
// is left for a future PR.)
const thinkClassifier = thinkTextRerouteEnabled() ? new ThinkTextClassifier() : null;
for await (const ev of streamChatImpl(attemptParams)) {
if (ev.type === 'content' || ev.type === 'reasoning') {
if (ev.type === 'content' && thinkClassifier) {
const routed = thinkClassifier.feed(ev.text);
if (routed.thinking) {
sawContent = true;
sawReasoning = true;
sawReasoningText += routed.thinking;
yield { type: 'reasoning', text: routed.thinking };
}
if (routed.text) {
sawContent = true;
sawText = true;
yield { type: 'content', text: routed.text };
}
} else if (ev.type === 'content' || ev.type === 'reasoning') {
if (ev.text) {
sawContent = true;
if (ev.type === 'content') sawText = true;
Expand All @@ -230,6 +261,17 @@ async function* streamChatWithEmptyRetry(params, { env = process.env } = {}) {
yield ev;
}
}
// Flush anything the classifier still holds BEFORE the rescue decision: an
// unterminated/undecided tail is delivered as text (visible beats dropped),
// and it must count as real text for the rescue gate above.
if (thinkClassifier) {
const rest = thinkClassifier.flush();
if (rest) {
sawContent = true;
sawText = true;
yield { type: 'content', text: rest };
}
}
// swe-1-7 (Kimi K2 fine-tune) intermittently spends the whole turn in reasoning
// declaring tool intent without emitting the call; a corrective nudge measurably
// (24/24 live probe) forces emission; empty assistant turns poison upstream into
Expand Down
10 changes: 10 additions & 0 deletions src/handlers/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ function mapStopReason(finishReason) {
return STOP_REASON_MAP[finishReason] || 'end_turn';
}

// Item 1 (loop break) classifier moved to the connect layer: leading think-tagged
// CONTENT is now reclassified into the reasoning channel at the stream-event level
// in devin-connect-openai.js (streamChatWithEmptyRetry), BEFORE the #238 rescue
// decision — see docs/GOAL-2026-08-06-PR-REWORK.md. The egress translators here stay
// passive: they render whatever channel the events arrive on.

// B5: Anthropic sets stop_reason:'stop_sequence' AND echoes the matched string
// in stop_sequence when generation halts on a caller-supplied stop sequence.
// The internal OpenAI path reports finish_reason:'stop' for both a natural
Expand Down Expand Up @@ -797,6 +803,10 @@ export function openAIToAnthropic(result, model, msgId, cachePolicy = null, stop
});
}
} else {
// Invariant: the text block is ALWAYS present, even when empty. The think-tag
// reroute lives in the connect layer now (devin-connect-openai.js), so by the
// time a completion reaches this translator the content channel carries no
// think markers — a reasoning-only turn arrives as reasoning_content only.
content.push({ type: 'text', text: choice?.message?.content || '' });
}
// B5: resolve stop_reason (incl. content_filter→refusal) and back-fill
Expand Down
106 changes: 106 additions & 0 deletions src/response-classifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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).
feed(delta) {
if (!delta) return { text: '', thinking: '' };
if (this.mode === 'text') return { text: delta, thinking: '' };
if (this.mode === 'think') return this._feedThink(delta);
return this._feedUndecided(delta);
}

_feedUndecided(delta) {
this.pending += delta;

// Enough held without a decision -> it is plain text; commit.
if (this.pending.length > MAX_LEAD) return this._commitText();

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 this._feedThink(''); // process the remainder of this same delta
}
// Marker present but real text precedes it -> inline, not a leak.
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 { text: '', thinking: '' }; // only whitespace so far
if (THINK_OPEN.startsWith(core)) return { text: '', thinking: '' }; // partial marker
return this._commitText(); // definitively not a leading marker
}

_commitText() {
const out = this.pending;
this.pending = '';
this.mode = 'text';
return { text: out, thinking: '' };
}

_feedThink(delta) {
this.pending += delta;

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
const cont = this._feedUndecided(rest);
return { text: cont.text, thinking: span + cont.thinking };
}

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 { text: '', thinking: '' }; // 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 };
110 changes: 109 additions & 1 deletion test/devin-connect-openai.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, it } from 'node:test';
import { afterEach, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
toChatCompletion,
Expand Down Expand Up @@ -1295,3 +1295,111 @@ describe('thinking-only rescue & promotion', () => {
});
});

// Pin tests — GOAL-2026-08-06-PR-REWORK item 3. The leading think-tag reroute
// moved from the messages.js egress translators into the connect layer, at the
// stream-event level (streamChatWithEmptyRetry), BEFORE the #238 rescue
// decision. These pin the combined behavior: reclassification + rescue + the
// egress invariant "text block always present".
describe('think-text reroute (connect layer, DEVIN_CONNECT_THINKTEXT_REROUTE)', () => {
const OPEN = '<' + 'think' + '>';
const CLOSE = '<' + '/' + 'think' + '>';
const SAMPLE_TOOLS = [{ type: 'function', function: { name: 'read_file' } }];
let prev;
beforeEach(() => { prev = process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = '1'; });
afterEach(() => { if (prev === undefined) delete process.env.DEVIN_CONNECT_THINKTEXT_REROUTE; else process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = prev; });

it('(a) whole turn = think block: the #238 rescue fires and the client gets an answer', async () => {
// Attempt 1 is an ENTIRE turn on the content channel, think-tagged. With the
// classifier at the event level this decodes to reasoning-only, so sawText
// stays false and the rescue fires naturally — it never did before the move:
// the egress reroute ran AFTER the rescue had already given up, leaving a
// reasoning-only finish (#238 form, APIEmptyResponseError on strict clients).
let callCount = 0;
let lastParams = null;
__setStreamChatForTest(async function* (params) {
callCount++;
lastParams = params;
if (callCount === 1) {
yield { type: 'content', text: OPEN + 'Let me think through the whole request. ' + CLOSE };
yield { type: 'finish', reason: 'stop', usage: null };
} else {
yield { type: 'content', text: 'Here is the answer.' };
yield { type: 'finish', reason: 'stop', usage: null };
}
});
const frames = [];
const result = await streamChatCompletion(
{ model: 'swe-1-7', messages: [{ role: 'user', content: 'hi' }], tools: SAMPLE_TOOLS },
(f) => frames.push(f),
{ emulateTools: true },
);
assert.equal(callCount, 2, 'the #238 rescue must fire for a whole-think turn');
assert.ok(
lastParams.messages.at(-1).content.includes('Stop reasoning. Emit the tool call markup now.'),
'the rescue nudge reaches the second attempt',
);
// The rescued attempt's answer is what reaches the client — the abandoned
// think-only attempt is not concatenated onto it.
assert.equal(result.content, 'Here is the answer.');
});

it('(a) whole-think turn without tools: promotion keeps a visible answer (no empty message)', async () => {
// No tools -> no rescue (plain chat legitimately ends in reasoning/text). The
// think-only content must still reach the client as the visible answer via
// the existing reasoning->content promotion, so the anthropic egress below
// (openAIToAnthropic) always has a text block to push.
__setStreamChatForTest(fakeStream([
{ type: 'content', text: OPEN + 'reasoning only, no tools. ' + CLOSE },
{ type: 'finish', reason: 'stop', usage: null },
]));
const { body } = await toChatCompletion({ model: 'swe-1-7', messages: [] });
const msg = body.choices[0].message;
assert.equal(msg.content, 'reasoning only, no tools. ', 'promoted into the visible content');
assert.equal(msg.reasoning_content, undefined, 'promotion moves, not copies');
});

it('(b) think + answer still splits into thinking and text', async () => {
__setStreamChatForTest(fakeStream([
{ type: 'content', text: OPEN + 'inner reasoning. ' + CLOSE },
{ type: 'content', text: 'The answer.' },
{ type: 'finish', reason: 'stop', usage: null },
]));
const { body } = await toChatCompletion({ model: 'swe-1-7', messages: [] });
const msg = body.choices[0].message;
assert.equal(msg.reasoning_content, 'inner reasoning. ', 'think span lands on the reasoning channel');
assert.equal(msg.content, 'The answer.', 'the answer stays on the content channel');
});

it('(c) stream variant: think + answer renders as reasoning_content deltas then content deltas', async () => {
__setStreamChatForTest(fakeStream([
{ type: 'content', text: OPEN + 'inner reasoning. ' + CLOSE },
{ type: 'content', text: 'The answer.' },
{ type: 'finish', reason: 'stop', usage: null },
]));
const frames = [];
const result = await streamChatCompletion(
{ model: 'swe-1-7', messages: [] },
(f) => frames.push(f),
{},
);
assert.equal(result.reasoning, 'inner reasoning. ');
assert.equal(result.content, 'The answer.');
const reasoningDeltas = frames.filter((f) => f.choices?.[0]?.delta?.reasoning_content).map((f) => f.choices[0].delta.reasoning_content).join('');
assert.equal(reasoningDeltas, 'inner reasoning. ');
const contentDeltas = frames.filter((f) => f.choices?.[0]?.delta?.content).map((f) => f.choices[0].delta.content).join('');
assert.equal(contentDeltas, 'The answer.');
});

it('respects the gate: with DEVIN_CONNECT_THINKTEXT_REROUTE off, think-tagged content stays text', async () => {
process.env.DEVIN_CONNECT_THINKTEXT_REROUTE = '0';
__setStreamChatForTest(fakeStream([
{ type: 'content', text: OPEN + 'inner reasoning. ' + CLOSE + 'The answer.' },
{ type: 'finish', reason: 'stop', usage: null },
]));
const { body } = await toChatCompletion({ model: 'swe-1-7', messages: [] });
const msg = body.choices[0].message;
assert.equal(msg.content, OPEN + 'inner reasoning. ' + CLOSE + 'The answer.');
assert.equal(msg.reasoning_content, undefined);
});
});

Loading