@melaya/runner 1.1.47 → 1.1.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/dist/assistantHost.py +30 -4
- package/dist/connection.js +44 -7
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -230,21 +230,47 @@ class _RedactingStdout:
|
|
|
230
230
|
def _scrub(self, s: str) -> str:
|
|
231
231
|
return self._B64.sub(lambda m: f"<redacted base64 {len(m.group(0))} bytes>", s)
|
|
232
232
|
|
|
233
|
+
def _flush_transcript(self):
|
|
234
|
+
# Send any buffered agentscope transcript to STDERR (redacted). Keeping it
|
|
235
|
+
# off stdout means the runner's stdout channel carries ONLY `MELASSIST `
|
|
236
|
+
# protocol frames: a transcript print can never glue onto a frame and be
|
|
237
|
+
# dropped (the missing-delta / garbled-fragment bug), and frame parsing on
|
|
238
|
+
# the runner stays cheap — no multi-KB base64 transcript lines flooding its
|
|
239
|
+
# event loop (which was spiking lag and dropping the socket heartbeat).
|
|
240
|
+
if self._buf:
|
|
241
|
+
try:
|
|
242
|
+
sys.stderr.write(self._scrub(self._buf))
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
self._buf = ""
|
|
246
|
+
|
|
233
247
|
def write(self, s):
|
|
234
248
|
try:
|
|
249
|
+
# A protocol frame is emitted in ONE write() call, whole and
|
|
250
|
+
# newline-terminated (see _emit). Route it to the REAL stdout byte for
|
|
251
|
+
# byte (no scrub — frames are short structured JSON), after flushing any
|
|
252
|
+
# pending partial transcript so nothing can glue onto it.
|
|
253
|
+
if s.startswith(_EVENT_PREFIX):
|
|
254
|
+
self._flush_transcript()
|
|
255
|
+
self._real.write(s)
|
|
256
|
+
self._real.flush()
|
|
257
|
+
return len(s)
|
|
258
|
+
# Everything else = agentscope's verbatim message transcript → stderr,
|
|
259
|
+
# line-buffered + base64-redacted, so it never pollutes the protocol.
|
|
235
260
|
self._buf += s
|
|
236
261
|
while "\n" in self._buf:
|
|
237
262
|
line, self._buf = self._buf.split("\n", 1)
|
|
238
|
-
|
|
263
|
+
try:
|
|
264
|
+
sys.stderr.write(self._scrub(line) + "\n")
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
239
267
|
return len(s)
|
|
240
268
|
except Exception:
|
|
241
269
|
return self._real.write(s)
|
|
242
270
|
|
|
243
271
|
def flush(self):
|
|
244
272
|
try:
|
|
245
|
-
|
|
246
|
-
self._real.write(self._scrub(self._buf))
|
|
247
|
-
self._buf = ""
|
|
273
|
+
self._flush_transcript()
|
|
248
274
|
self._real.flush()
|
|
249
275
|
except Exception:
|
|
250
276
|
pass
|
package/dist/connection.js
CHANGED
|
@@ -55,6 +55,11 @@ try {
|
|
|
55
55
|
}
|
|
56
56
|
catch { /* dev tree layout differs — non-fatal */ }
|
|
57
57
|
const activeAssistants = new Map();
|
|
58
|
+
// Line prefix the assistant host uses to frame protocol events on stdout
|
|
59
|
+
// (mirror of assistantHost.py `_EVENT_PREFIX`). Everything else on stdout is
|
|
60
|
+
// raw agentscope transcript (now routed to stderr host-side, but we still parse
|
|
61
|
+
// defensively in case a frame arrives glued to a raw print).
|
|
62
|
+
const _EVENT_PREFIX = "MELASSIST ";
|
|
58
63
|
// Warm pool (size 1): a fully-booted host held in reserve, ready to be ADOPTED
|
|
59
64
|
// by the next new session whose config+generation match — skipping the ~7s cold
|
|
60
65
|
// boot (import + build_agent). Pure optimization: any mismatch or unhealthy
|
|
@@ -72,6 +77,11 @@ let warmSpareSeed = null;
|
|
|
72
77
|
// SYNCHRONOUSLY here closes that window.
|
|
73
78
|
const bootingAssistants = new Set();
|
|
74
79
|
const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
|
|
80
|
+
// OOM backstop for the assistant stdout line-framer: if a single unterminated
|
|
81
|
+
// line (no newline) grows past this, flush + reset rather than let `+=` build an
|
|
82
|
+
// unbounded string. Generous — legitimate frames (tool results, transcripts) are
|
|
83
|
+
// far smaller; a line this large would exceed the socket buffer anyway.
|
|
84
|
+
const ASSISTANT_STDOUT_MAX_CARRY = 64 * 1024 * 1024;
|
|
75
85
|
export async function connect(opts) {
|
|
76
86
|
const spinner = ora("Connecting to Melaya...").start();
|
|
77
87
|
// Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
|
|
@@ -1319,15 +1329,37 @@ export async function connect(opts) {
|
|
|
1319
1329
|
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
|
|
1320
1330
|
proc.stdout?.on("data", (data) => {
|
|
1321
1331
|
session.lastActivity = Date.now();
|
|
1332
|
+
// O(n) line framing: accumulate, split ONCE, carry the trailing partial.
|
|
1333
|
+
// The old `buf += …; while(indexOf/slice)` loop re-sliced the WHOLE buffer
|
|
1334
|
+
// per line — O(n²) on a big burst (a large tool result / browser tree in
|
|
1335
|
+
// the transcript). That blocked the Node event loop long enough that
|
|
1336
|
+
// Socket.IO couldn't service the connection (send the engine.io pong), so
|
|
1337
|
+
// the server's pingTimeout dropped the socket MID-TURN — the "heartbeat
|
|
1338
|
+
// stops while working" failure. Keeping this O(n) means the heartbeat and
|
|
1339
|
+
// pong keep flowing no matter how much the host streams.
|
|
1322
1340
|
session.stdoutBuf += data.toString();
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1341
|
+
const parts = session.stdoutBuf.split("\n");
|
|
1342
|
+
session.stdoutBuf = parts.pop() ?? "";
|
|
1343
|
+
// OOM backstop: an unterminated line that keeps growing across chunks would
|
|
1344
|
+
// make the `+=` above quadratic again. Flush it as its own line and reset.
|
|
1345
|
+
if (session.stdoutBuf.length > ASSISTANT_STDOUT_MAX_CARRY) {
|
|
1346
|
+
parts.push(session.stdoutBuf);
|
|
1347
|
+
session.stdoutBuf = "";
|
|
1348
|
+
console.log(chalk.yellow(` [assistant-stdout] flushed oversized line (${(ASSISTANT_STDOUT_MAX_CARRY / 1048576) | 0}MB+) to protect the event loop`));
|
|
1349
|
+
}
|
|
1350
|
+
for (const line of parts) {
|
|
1327
1351
|
const t = line.trim();
|
|
1328
|
-
|
|
1352
|
+
// A protocol frame can arrive GLUED to a preceding raw print that had no
|
|
1353
|
+
// trailing newline (agentscope prints the agent transcript to the same
|
|
1354
|
+
// stdout), e.g. `Assistant: GotMELASSIST {"kind":"delta",…}`. Find the
|
|
1355
|
+
// prefix ANYWHERE in the line, not just at the start, or that delta is
|
|
1356
|
+
// dropped as raw output (the missing-deltas / garbled-fragment bug).
|
|
1357
|
+
const mi = t.indexOf(_EVENT_PREFIX);
|
|
1358
|
+
if (mi >= 0) {
|
|
1359
|
+
if (mi > 0 && opts.verbose)
|
|
1360
|
+
console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
|
|
1329
1361
|
try {
|
|
1330
|
-
const parsed = JSON.parse(t.slice(
|
|
1362
|
+
const parsed = JSON.parse(t.slice(mi + _EVENT_PREFIX.length));
|
|
1331
1363
|
if (typeof parsed?.memoryWatermark === "number")
|
|
1332
1364
|
session.memoryWatermark = parsed.memoryWatermark;
|
|
1333
1365
|
if (parsed?.kind === "ready") {
|
|
@@ -1343,7 +1375,12 @@ export async function connect(opts) {
|
|
|
1343
1375
|
if (session.forward)
|
|
1344
1376
|
socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
|
|
1345
1377
|
}
|
|
1346
|
-
catch {
|
|
1378
|
+
catch {
|
|
1379
|
+
// The literal "MELASSIST " appeared inside raw text (not a real frame)
|
|
1380
|
+
// — the JSON tail won't parse; treat the whole line as raw output.
|
|
1381
|
+
if (t && opts.verbose)
|
|
1382
|
+
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|
|
1383
|
+
}
|
|
1347
1384
|
}
|
|
1348
1385
|
else if (t && opts.verbose) {
|
|
1349
1386
|
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|