@yeaft/webchat-agent 1.0.47 → 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/connection/upgrade.js +42 -4
- package/package.json +1 -1
- package/yeaft/llm/adapter.js +111 -0
- package/yeaft/llm/anthropic.js +8 -4
- package/yeaft/llm/openai-responses.js +10 -5
package/connection/upgrade.js
CHANGED
|
@@ -377,6 +377,17 @@ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestV
|
|
|
377
377
|
* (`getServiceName` / `getLaunchdPlistPath`), so a named instance restarts
|
|
378
378
|
* `yeaft-agent@<id>` instead of the bare `yeaft-agent` default.
|
|
379
379
|
*
|
|
380
|
+
* On systemd the script pins XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS (the
|
|
381
|
+
* detached upgrade shell may not inherit them, and `systemctl --user` needs
|
|
382
|
+
* them to reach the user manager) and logs + retries a failed restart instead
|
|
383
|
+
* of failing silently.
|
|
384
|
+
*
|
|
385
|
+
* The env-pin is systemd-specific: launchd reaches its manager via the
|
|
386
|
+
* inherited Mach bootstrap port and PM2 via a fixed named pipe, so neither
|
|
387
|
+
* needs these vars. NOTE this is only about the *env* asymmetry — the launchd
|
|
388
|
+
* branch does NOT yet have the same restart-failure logging/retry hardening;
|
|
389
|
+
* that resilience gap is deferred, not "launchd is already safe".
|
|
390
|
+
*
|
|
380
391
|
* @param {object} opts
|
|
381
392
|
* @param {string} opts.pkgName npm package name
|
|
382
393
|
* @param {string} opts.installDir install dir (local install) — used as cwd
|
|
@@ -438,6 +449,18 @@ export function buildUnixUpgradeScript({
|
|
|
438
449
|
// 停止服务管理器的自动重启
|
|
439
450
|
if (isSystemd) {
|
|
440
451
|
shLines.push(
|
|
452
|
+
// `systemctl --user` locates the user manager via XDG_RUNTIME_DIR (and
|
|
453
|
+
// its bus via DBUS_SESSION_BUS_ADDRESS). The agent normally inherits both
|
|
454
|
+
// from its systemd-managed environment, but the detached upgrade shell
|
|
455
|
+
// cannot rely on that always being populated — if either is missing,
|
|
456
|
+
// `systemctl --user` fails with "Failed to connect to bus" and BOTH the
|
|
457
|
+
// stop and the restart silently no-op, stranding the agent offline after
|
|
458
|
+
// upgrade. Pin per-user defaults (keeping any inherited value) before the
|
|
459
|
+
// first systemctl call so the whole sequence can reach the manager.
|
|
460
|
+
'YEAFT_UID="$(id -u)"',
|
|
461
|
+
'export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$YEAFT_UID}"',
|
|
462
|
+
'export DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=/run/user/$YEAFT_UID/bus}"',
|
|
463
|
+
'',
|
|
441
464
|
'# Stop systemd service to prevent restart loop',
|
|
442
465
|
`systemctl --user stop "${serviceName}" 2>/dev/null`,
|
|
443
466
|
'sleep 1',
|
|
@@ -472,9 +495,22 @@ export function buildUnixUpgradeScript({
|
|
|
472
495
|
// 重新启动服务
|
|
473
496
|
if (isSystemd) {
|
|
474
497
|
shLines.push(
|
|
475
|
-
'# Restart systemd service',
|
|
476
|
-
`systemctl
|
|
477
|
-
'
|
|
498
|
+
'# Restart systemd service. Capture failure explicitly: a silent',
|
|
499
|
+
'# `systemctl start` failure here is exactly what strands the agent',
|
|
500
|
+
'# offline, so log the exit code + reason and retry once before giving up.',
|
|
501
|
+
`if systemctl --user start "${serviceName}"; then`,
|
|
502
|
+
' echo "[Upgrade] Service restarted via systemd"',
|
|
503
|
+
'else',
|
|
504
|
+
' START_RC=$?',
|
|
505
|
+
` echo "[Upgrade] systemctl start failed (rc=$START_RC); retrying after reload..."`,
|
|
506
|
+
' systemctl --user daemon-reload || true',
|
|
507
|
+
' sleep 2',
|
|
508
|
+
` if systemctl --user start "${serviceName}"; then`,
|
|
509
|
+
' echo "[Upgrade] Service restarted via systemd (after retry)"',
|
|
510
|
+
' else',
|
|
511
|
+
` echo "[Upgrade] ERROR: systemctl start still failing (rc=$?). Manual start required: systemctl --user start ${serviceName}"`,
|
|
512
|
+
' fi',
|
|
513
|
+
'fi',
|
|
478
514
|
);
|
|
479
515
|
} else if (isLaunchd) {
|
|
480
516
|
shLines.push(
|
|
@@ -492,7 +528,9 @@ export function buildUnixUpgradeScript({
|
|
|
492
528
|
|
|
493
529
|
function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
|
|
494
530
|
const configDir = getConfigDir(instanceId);
|
|
495
|
-
|
|
531
|
+
// Create logs/ too: the generated script's `exec > "$LOGFILE" 2>&1` aborts
|
|
532
|
+
// the whole upgrade silently if that directory is missing.
|
|
533
|
+
mkdirSync(join(configDir, 'logs'), { recursive: true });
|
|
496
534
|
const shPath = join(configDir, 'upgrade.sh');
|
|
497
535
|
|
|
498
536
|
const script = buildUnixUpgradeScript({
|
package/package.json
CHANGED
package/yeaft/llm/adapter.js
CHANGED
|
@@ -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
|
package/yeaft/llm/anthropic.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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;
|
|
349
|
-
|
|
350
|
-
|
|
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();
|