@yeaft/webchat-agent 1.0.48 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.48",
3
+ "version": "1.0.49",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -117,6 +117,117 @@ export class LLMAbortError extends Error {
117
117
  }
118
118
  }
119
119
 
120
+ /**
121
+ * Default cap on a single un-terminated SSE line. A well-formed SSE stream
122
+ * terminates every `data:` line with `\n`, so the live buffer never exceeds
123
+ * one event. A malfunctioning gateway can instead emit a multi-megabyte run
124
+ * with no newline; without a cap, the buffer grows unbounded and (before the
125
+ * incremental scan below) the parse went quadratic, freezing the event loop
126
+ * long enough to starve the WS heartbeat and drop the agent offline. 64 MiB
127
+ * is far above any legitimate single SSE event yet bounds the damage.
128
+ */
129
+ export const DEFAULT_SSE_MAX_LINE_BYTES = 64 * 1024 * 1024;
130
+
131
+ /**
132
+ * Incremental, O(n) line splitter for SSE byte streams.
133
+ *
134
+ * The previous per-adapter pattern was `buffer += chunk; lines =
135
+ * buffer.split('\n'); buffer = lines.pop()`. When a single line spans many
136
+ * chunks (no `\n` yet), each chunk re-scanned and re-split the entire growing
137
+ * buffer — O(n²) total, which on a multi-MiB un-terminated line blocks the
138
+ * main thread for tens of seconds to minutes (measured: ~35 s at 40 MiB),
139
+ * freezing every event-loop task including the heartbeat `setInterval` and the
140
+ * `ws.on('pong')` handler. The agent then sees "No pong" and terminates its
141
+ * own healthy connection.
142
+ *
143
+ * This buffer scans only each newly-arrived chunk for `\n` and holds the
144
+ * still-incomplete trailing line as an array of fragments (joined only when a
145
+ * newline finally completes it), so total work is linear in bytes received
146
+ * regardless of how a line is chunked. It also enforces `maxLineBytes`: a
147
+ * single line that exceeds the cap with no terminator is treated as a
148
+ * malformed stream — `push()` throws a retryable LLMServerError (which the
149
+ * adapter's stream loop propagates through classifyFetchError) rather than
150
+ * accumulating without bound.
151
+ *
152
+ * Note: the cap is measured in JS string `.length` (UTF-16 code units), not
153
+ * exact UTF-8 bytes. It is a coarse upper-bound guard against unbounded
154
+ * growth, not a precise byte accountant.
155
+ */
156
+ export class SseLineBuffer {
157
+ /** @param {{ maxLineBytes?: number }} [opts] */
158
+ constructor({ maxLineBytes = DEFAULT_SSE_MAX_LINE_BYTES } = {}) {
159
+ this.maxLineBytes = Number.isFinite(maxLineBytes) && maxLineBytes > 0
160
+ ? Math.floor(maxLineBytes)
161
+ : DEFAULT_SSE_MAX_LINE_BYTES;
162
+ /**
163
+ * Fragments of the current (incomplete) line, in arrival order. Kept as an
164
+ * array — NOT a concatenated string — because `string += chunk` reallocates
165
+ * and copies the whole growing tail every call, which is itself O(n²) on a
166
+ * long un-terminated line even with an incremental newline scan. Pushing a
167
+ * fragment is O(1); we only `join` when a newline actually completes a line.
168
+ * @type {string[]}
169
+ */
170
+ this._frags = [];
171
+ /** Running byte length of `_frags` — avoids re-summing to enforce the cap. */
172
+ this._pendingLen = 0;
173
+ }
174
+
175
+ /**
176
+ * Append a chunk and return every newly-completed line (newline stripped),
177
+ * in order. The trailing incomplete line stays buffered for the next call.
178
+ * Lines may be empty strings (SSE uses blank lines as event separators);
179
+ * callers filter as needed.
180
+ *
181
+ * @param {string} chunk
182
+ * @returns {string[]}
183
+ * @throws {LLMServerError} when an un-terminated line exceeds `maxLineBytes`
184
+ */
185
+ push(chunk) {
186
+ if (!chunk) return [];
187
+ const lines = [];
188
+ let start = 0;
189
+ // Scan only THIS chunk for newlines. Bytes before the first newline finish
190
+ // the buffered partial line; bytes between newlines are whole lines; bytes
191
+ // after the last newline become the new partial. Work is linear in chunk
192
+ // length, and the buffered tail is never concatenated until a newline lands.
193
+ let nl;
194
+ while ((nl = chunk.indexOf('\n', start)) !== -1) {
195
+ const segment = chunk.slice(start, nl);
196
+ if (this._frags.length > 0) {
197
+ this._frags.push(segment);
198
+ lines.push(this._frags.join(''));
199
+ this._frags = [];
200
+ this._pendingLen = 0;
201
+ } else {
202
+ lines.push(segment);
203
+ }
204
+ start = nl + 1;
205
+ }
206
+ // Trailing fragment after the last newline (or the whole chunk if none):
207
+ // buffer it as an O(1) push rather than a string concat.
208
+ if (start < chunk.length) {
209
+ const tail = start === 0 ? chunk : chunk.slice(start);
210
+ this._frags.push(tail);
211
+ this._pendingLen += tail.length;
212
+ if (this._pendingLen > this.maxLineBytes) {
213
+ // LLMServerError is retryable by class (engine.js retries on
214
+ // `instanceof LLMServerError`), matching the sibling throws in the
215
+ // adapters — no `.retryable` flag needed on the throw path.
216
+ throw new LLMServerError(
217
+ `SSE line exceeded ${this.maxLineBytes} bytes without a newline — treating as malformed stream`,
218
+ 0,
219
+ );
220
+ }
221
+ }
222
+ return lines;
223
+ }
224
+
225
+ /** The unconsumed trailing partial line (no newline yet). */
226
+ get pending() {
227
+ return this._frags.length === 1 ? this._frags[0] : this._frags.join('');
228
+ }
229
+ }
230
+
120
231
  /**
121
232
  * Read one chunk from a Fetch stream with a silence timeout. This is not a
122
233
  * total request deadline: every received chunk gets a fresh budget. A caller
@@ -18,6 +18,7 @@ import {
18
18
  readStreamChunkWithIdleTimeout,
19
19
  redactRawRequest,
20
20
  safeHeaders,
21
+ SseLineBuffer,
21
22
  } from './adapter.js';
22
23
  import {
23
24
  normalizeEffort,
@@ -302,7 +303,12 @@ export class AnthropicAdapter extends LLMAdapter {
302
303
  // Parse SSE stream
303
304
  const reader = response.body.getReader();
304
305
  const decoder = new TextDecoder();
305
- let buffer = '';
306
+ // Incremental O(n) line splitter (see SseLineBuffer): the old
307
+ // `buffer += chunk; buffer.split('\n')` pattern went quadratic on a
308
+ // multi-MiB un-terminated line and froze the event loop long enough to
309
+ // starve the WS heartbeat. The buffer also caps a single newline-less
310
+ // line and throws a retryable error on a malformed stream.
311
+ const sseLines = new SseLineBuffer();
306
312
  // task-327d: index-keyed per-block state. Anthropic streams content
307
313
  // blocks sequentially today, but the protocol exposes `event.index`
308
314
  // precisely because that's not guaranteed. Dispatch in
@@ -333,10 +339,8 @@ export class AnthropicAdapter extends LLMAdapter {
333
339
  if (done) break;
334
340
 
335
341
  const chunkText = decoder.decode(value, { stream: true });
336
- buffer += chunkText;
337
342
  rawSseBodyChunks.push(chunkText);
338
- const lines = buffer.split('\n');
339
- buffer = lines.pop() || ''; // Keep incomplete line
343
+ const lines = sseLines.push(chunkText);
340
344
 
341
345
  for (const line of lines) {
342
346
  if (!line.startsWith('data: ')) continue;
@@ -37,6 +37,7 @@ import {
37
37
  readStreamChunkWithIdleTimeout,
38
38
  redactRawRequest,
39
39
  safeHeaders,
40
+ SseLineBuffer,
40
41
  } from './adapter.js';
41
42
  import {
42
43
  normalizeEffort,
@@ -315,7 +316,12 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
315
316
 
316
317
  const reader = response.body.getReader();
317
318
  const decoder = new TextDecoder();
318
- let buffer = '';
319
+ // Incremental O(n) line splitter (see SseLineBuffer): the old
320
+ // `buffer += chunk; buffer.split('\n')` pattern went quadratic on a
321
+ // multi-MiB un-terminated line and froze the event loop long enough to
322
+ // starve the WS heartbeat. The buffer also caps a single newline-less
323
+ // line and throws a retryable error on a malformed stream.
324
+ const sseLines = new SseLineBuffer();
319
325
 
320
326
  /** Accumulate tool call arguments by output_index.
321
327
  * Value: { callId, name, arguments } */
@@ -342,12 +348,11 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
342
348
  });
343
349
  if (done) break;
344
350
  const chunkText = decoder.decode(value, { stream: true });
345
- buffer += chunkText;
346
351
  rawSseBodyChunks.push(chunkText);
347
352
 
348
- // SSE events are separated by blank lines; split on \n
349
- const lines = buffer.split('\n');
350
- buffer = lines.pop() || '';
353
+ // SSE events are separated by blank lines; SseLineBuffer yields each
354
+ // completed line (newline stripped) in O(n) total.
355
+ const lines = sseLines.push(chunkText);
351
356
 
352
357
  for (const rawLine of lines) {
353
358
  const line = rawLine.trimEnd();