@tiens.nguyen/gonext-local-worker 1.0.260 → 1.0.262
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/gonext-repl.mjs +153 -9
- package/gonext_agent_chat.py +164 -15
- package/package.json +1 -1
package/gonext-repl.mjs
CHANGED
|
@@ -383,6 +383,30 @@ rl.on("close", () => {
|
|
|
383
383
|
w(null);
|
|
384
384
|
}
|
|
385
385
|
});
|
|
386
|
+
// Mouse click-to-expand (task #96): while a turn runs we turn ON xterm button tracking so a
|
|
387
|
+
// click on the live "thinking" status can expand it (see runAgentTurn). Trade-off the user
|
|
388
|
+
// accepted: with tracking on, the terminal's own drag-to-select/copy is captured by us — the
|
|
389
|
+
// user holds Option/Shift to select text meanwhile. ?1000 = report button press+release only
|
|
390
|
+
// (no motion, so we don't flood on mouse-move); ?1006 = SGR extended coords (no 223-col cap).
|
|
391
|
+
const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
|
|
392
|
+
const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
|
|
393
|
+
let _mouseEnabled = false;
|
|
394
|
+
const enableMouse = () => {
|
|
395
|
+
if (_mouseEnabled || !process.stdin.isTTY) return;
|
|
396
|
+
process.stdout.write(MOUSE_ON);
|
|
397
|
+
_mouseEnabled = true;
|
|
398
|
+
};
|
|
399
|
+
const disableMouse = () => {
|
|
400
|
+
if (!_mouseEnabled) return;
|
|
401
|
+
process.stdout.write(MOUSE_OFF);
|
|
402
|
+
_mouseEnabled = false;
|
|
403
|
+
};
|
|
404
|
+
// Safety net: never leave the user's terminal in mouse-reporting mode (would break their
|
|
405
|
+
// shell's selection) if we exit ungracefully.
|
|
406
|
+
process.on("exit", disableMouse);
|
|
407
|
+
// SGR mouse report: ESC [ < btn ; col ; row (M=press, m=release). A left-button PRESS is
|
|
408
|
+
// btn low-2-bits 0 with the wheel bit (64) clear.
|
|
409
|
+
const MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
386
410
|
const nextLine = () =>
|
|
387
411
|
_lineQueue.length > 0
|
|
388
412
|
? Promise.resolve(_lineQueue.shift())
|
|
@@ -1025,13 +1049,20 @@ const normalizeThought = (buf) => {
|
|
|
1025
1049
|
const t = String(buf || "");
|
|
1026
1050
|
const ci = t.search(/<code[\s>]/i);
|
|
1027
1051
|
const head = ci >= 0 ? t.slice(0, ci) : t;
|
|
1028
|
-
// 1) a genuine Thought prose line (before the code) — only if it isn't itself code
|
|
1052
|
+
// 1) a genuine Thought prose line (before the code) — only if it isn't itself code AND
|
|
1053
|
+
// reads like an actual clause. A streaming token boundary can leave the first
|
|
1054
|
+
// non-empty line as a single bare word (e.g. "string", when the model is rambling
|
|
1055
|
+
// about "multi-line strings"); shown alone on the blinking line it's meaningless and,
|
|
1056
|
+
// because liveThought only updates on a truthy value, it can FREEZE there through a
|
|
1057
|
+
// long silent prompt-eval ("string… (777s)"). Require ≥2 words and a little length so
|
|
1058
|
+
// a lone fragment falls through to the tool label / "Thinking…" instead.
|
|
1029
1059
|
const prose = head
|
|
1030
1060
|
.replace(/^\s*Thought:\s*/i, "")
|
|
1031
1061
|
.split(/\n/)
|
|
1032
1062
|
.map((x) => x.trim())
|
|
1033
1063
|
.find(Boolean);
|
|
1034
|
-
|
|
1064
|
+
const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
|
|
1065
|
+
if (proseIsClause && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
|
|
1035
1066
|
// 2) otherwise name the tool being called (from the code part).
|
|
1036
1067
|
const codePart = ci >= 0 ? t.slice(ci) : t;
|
|
1037
1068
|
const m = /(?:<code>\s*)?\b([a-z_]\w*)\s*\(\s*(?:path\s*=\s*)?["']?([^"'\n,)]*)/i.exec(codePart);
|
|
@@ -1040,6 +1071,52 @@ const normalizeThought = (buf) => {
|
|
|
1040
1071
|
return "";
|
|
1041
1072
|
};
|
|
1042
1073
|
|
|
1074
|
+
// The FULLER thought (task #96): the same Thought prose normalizeThought summarizes, but
|
|
1075
|
+
// NOT clipped to one clause/100 chars — collapsed to a single spaced string and capped so
|
|
1076
|
+
// the expanded (mouse-click) view has a few lines to show. Returns "" when the live line is
|
|
1077
|
+
// only a tool label (nothing extra to expand). Bounded by fenceThought's own 400-char cap.
|
|
1078
|
+
const thoughtFull = (buf) => {
|
|
1079
|
+
const t = String(buf || "");
|
|
1080
|
+
const ci = t.search(/<code[\s>]/i);
|
|
1081
|
+
const head = (ci >= 0 ? t.slice(0, ci) : t)
|
|
1082
|
+
.replace(/^\s*Thought:\s*/i, "")
|
|
1083
|
+
.replace(/\s+/g, " ")
|
|
1084
|
+
.trim();
|
|
1085
|
+
// Only worth expanding when there's a genuine prose clause (same gate as normalizeThought
|
|
1086
|
+
// path 1) — a bare fragment or pure code has nothing meaningful to unfold.
|
|
1087
|
+
const first = head.split(/\s+/).length;
|
|
1088
|
+
if (head.length < 6 || first < 2 || _thoughtLooksCodey(head)) return "";
|
|
1089
|
+
return head.slice(0, 400);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// Word-wrap a plain (ANSI-free) string into at most `maxLines` lines of width ≤ `width`,
|
|
1093
|
+
// appending an ellipsis to the last line when text was dropped (task #96 expand view).
|
|
1094
|
+
const wrapThought = (s, width, maxLines) => {
|
|
1095
|
+
const words = String(s || "").split(/\s+/).filter(Boolean);
|
|
1096
|
+
const lines = [];
|
|
1097
|
+
let cur = "";
|
|
1098
|
+
let truncated = false;
|
|
1099
|
+
for (let i = 0; i < words.length; i++) {
|
|
1100
|
+
const w = words[i];
|
|
1101
|
+
const next = cur ? cur + " " + w : w;
|
|
1102
|
+
if (next.length <= width) {
|
|
1103
|
+
cur = next;
|
|
1104
|
+
} else {
|
|
1105
|
+
if (cur) lines.push(cur);
|
|
1106
|
+
cur = w;
|
|
1107
|
+
if (lines.length >= maxLines) { truncated = true; cur = ""; break; }
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (cur && lines.length < maxLines) lines.push(cur);
|
|
1111
|
+
else if (cur) truncated = true;
|
|
1112
|
+
if (truncated && lines.length) {
|
|
1113
|
+
const last = lines[maxLines - 1] ?? lines[lines.length - 1];
|
|
1114
|
+
const room = Math.max(1, width - 1);
|
|
1115
|
+
lines[lines.length - 1] = (last.length > room ? last.slice(0, room) : last).replace(/\s+$/, "") + "…";
|
|
1116
|
+
}
|
|
1117
|
+
return lines;
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1043
1120
|
async function runAgentTurn(history) {
|
|
1044
1121
|
const res = await fetch(`${apiBase}/api/worker/agent-ask`, {
|
|
1045
1122
|
method: "POST",
|
|
@@ -1083,6 +1160,7 @@ async function runAgentTurn(history) {
|
|
|
1083
1160
|
let lastApprovalId = null; // the last command-approval request we've already answered
|
|
1084
1161
|
let carry = ""; // trailing partial content line held until its newline arrives
|
|
1085
1162
|
let statusShown = false; // a transient status line is currently on screen
|
|
1163
|
+
let statusLines = 2; // how many rows the status block currently occupies (grows when expanded)
|
|
1086
1164
|
let warnedPending = false;
|
|
1087
1165
|
// Transient poll failures (a network blip or a 5xx like the API's momentary "Worker
|
|
1088
1166
|
// key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
|
|
@@ -1117,6 +1195,7 @@ async function runAgentTurn(history) {
|
|
|
1117
1195
|
// "◐ working… (7s)" — instead of dumping the model's rambling reasoning and stamping a
|
|
1118
1196
|
// "✔ completed thought" on every token-stream micro-pause (the reported clutter).
|
|
1119
1197
|
const QUIET_MS = 700; // stream idle this long ⇒ we're waiting on the model
|
|
1198
|
+
const STALE_THOUGHT_MS = 25000; // no new tokens this long ⇒ the current Thought clause is stale (#95 A)
|
|
1120
1199
|
let lastContentAt = Date.now();
|
|
1121
1200
|
let thinking = false;
|
|
1122
1201
|
let thinkingSince = 0;
|
|
@@ -1130,14 +1209,21 @@ async function runAgentTurn(history) {
|
|
|
1130
1209
|
// random word. `fenceThought` accumulates the raw in-fence text for the current step.
|
|
1131
1210
|
let liveThought = "";
|
|
1132
1211
|
let fenceThought = "";
|
|
1212
|
+
// Task #96: the FULLER version of the current thought (for the click-to-expand view) and
|
|
1213
|
+
// whether the user has clicked to expand it. Both reset when the step's Thought changes.
|
|
1214
|
+
let fullLiveThought = "";
|
|
1215
|
+
let thoughtExpanded = false;
|
|
1133
1216
|
const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
|
|
1134
1217
|
|
|
1135
|
-
// The status is TWO in-place lines — the in-progress action, then the playful
|
|
1136
|
-
// under it
|
|
1137
|
-
//
|
|
1218
|
+
// The status is normally TWO in-place lines — the in-progress action, then the playful
|
|
1219
|
+
// word under it — but grows by a few grey lines when the thought is expanded (task #96).
|
|
1220
|
+
// Cursor sits at the end of the LAST line, so clearing = wipe it, then move-up-and-wipe
|
|
1221
|
+
// for each line above, leaving the cursor back at the status's origin for a redraw.
|
|
1138
1222
|
const clearStatus = () => {
|
|
1139
1223
|
if (!statusShown) return;
|
|
1140
|
-
|
|
1224
|
+
let seq = "\r\x1b[K";
|
|
1225
|
+
for (let i = 1; i < statusLines; i++) seq += "\x1b[1A\r\x1b[K";
|
|
1226
|
+
process.stdout.write(seq);
|
|
1141
1227
|
statusShown = false;
|
|
1142
1228
|
};
|
|
1143
1229
|
|
|
@@ -1154,6 +1240,13 @@ async function runAgentTurn(history) {
|
|
|
1154
1240
|
thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); lastWordBucket = 0;
|
|
1155
1241
|
}
|
|
1156
1242
|
const now = Date.now();
|
|
1243
|
+
// Age out a stale Thought clause: if the model has produced NO new stream content for a
|
|
1244
|
+
// while (e.g. a 150s+ silent prompt-eval on a huge context), the last clause is no longer
|
|
1245
|
+
// "what it's doing now" — drop it so the line falls back to the playful word / "Thinking…"
|
|
1246
|
+
// instead of freezing on an old thought while the timer climbs ("string… (777s)", #95 A).
|
|
1247
|
+
if (liveThought && now - lastContentAt > STALE_THOUGHT_MS) {
|
|
1248
|
+
liveThought = ""; fullLiveThought = ""; thoughtExpanded = false; // #96: nothing to expand once stale
|
|
1249
|
+
}
|
|
1157
1250
|
const secs = thinkSecs();
|
|
1158
1251
|
// Rotate the playful word once per 12s window (guarded so the fast ticker doesn't
|
|
1159
1252
|
// re-roll it many times within the same second).
|
|
@@ -1185,16 +1278,58 @@ async function runAgentTurn(history) {
|
|
|
1185
1278
|
const tokLabel =
|
|
1186
1279
|
dim(` · ${fmtTokens(latestCodeTokens)} tok`) +
|
|
1187
1280
|
orange(` · ${fmtTokens(sessionMaxCodeTokens)} max`);
|
|
1188
|
-
|
|
1281
|
+
let out =
|
|
1189
1282
|
`${green(BULLET)} ${green(`${fit(primary)}… (${secs}s)`)}` +
|
|
1190
|
-
|
|
1191
|
-
|
|
1283
|
+
"\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`;
|
|
1284
|
+
let drawn = 2;
|
|
1285
|
+
// Task #96: when the user has clicked the thought line, unfold the fuller thought below
|
|
1286
|
+
// it as up to 4 wrapped GREY lines (dim). Only when there's genuinely more to show than
|
|
1287
|
+
// the truncated primary (a live command / bare "Thinking" has nothing to expand).
|
|
1288
|
+
if (thoughtExpanded && fullLiveThought && !runningCmd) {
|
|
1289
|
+
const wrapped = wrapThought(fullLiveThought, max, 4);
|
|
1290
|
+
for (const wl of wrapped) { out += "\n " + dim(wl); drawn++; }
|
|
1291
|
+
}
|
|
1292
|
+
process.stdout.write(out);
|
|
1293
|
+
statusLines = drawn;
|
|
1192
1294
|
statusShown = true;
|
|
1193
1295
|
};
|
|
1194
1296
|
// 120ms so the spinner animates smoothly (the frame math above uses the wall clock, so
|
|
1195
1297
|
// seconds still tick once/sec). Only redraws when the model has gone quiet >QUIET_MS.
|
|
1196
1298
|
const ticker = setInterval(tick, 120);
|
|
1197
1299
|
|
|
1300
|
+
// --- Click-to-expand the thinking line (task #96) --------------------------------------
|
|
1301
|
+
// A left-click on the live status block toggles the fuller thought (3-4 grey lines). The
|
|
1302
|
+
// status block sits at the bottom of the viewport (new output keeps scrolling it there),
|
|
1303
|
+
// so we hit-test the click row against the bottom `statusLines` rows. We enable xterm
|
|
1304
|
+
// mouse reporting only for the duration of the turn and always turn it back off in the
|
|
1305
|
+
// finally + on process exit, so the user's shell is never left in mouse-capture mode.
|
|
1306
|
+
const onStatusClick = (row) => {
|
|
1307
|
+
if (!statusShown) return;
|
|
1308
|
+
const rows = process.stdout.rows || 24;
|
|
1309
|
+
const top = rows - statusLines + 1; // 1-based viewport row of the first status line
|
|
1310
|
+
if (row < top || row > rows) return; // click landed outside the status block
|
|
1311
|
+
// Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
|
|
1312
|
+
// so we don't flicker an empty expand.
|
|
1313
|
+
if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
|
|
1314
|
+
thoughtExpanded = !thoughtExpanded;
|
|
1315
|
+
clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
|
|
1316
|
+
};
|
|
1317
|
+
const onMouseData = (chunk) => {
|
|
1318
|
+
if (!following) return;
|
|
1319
|
+
const s = chunk.toString("latin1");
|
|
1320
|
+
if (s.indexOf("\x1b[<") === -1) return; // fast reject: no SGR mouse report in this chunk
|
|
1321
|
+
MOUSE_RE.lastIndex = 0;
|
|
1322
|
+
let m;
|
|
1323
|
+
while ((m = MOUSE_RE.exec(s))) {
|
|
1324
|
+
const btn = parseInt(m[1], 10);
|
|
1325
|
+
const row = parseInt(m[3], 10);
|
|
1326
|
+
const isPress = m[4] === "M";
|
|
1327
|
+
if (isPress && (btn & 0b11) === 0 && (btn & 64) === 0) onStatusClick(row); // left press
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
if (process.stdin.isTTY) process.stdin.on("data", onMouseData);
|
|
1331
|
+
enableMouse();
|
|
1332
|
+
|
|
1198
1333
|
// Called right before any real (bullet/edit-card/answer) content prints: drop the
|
|
1199
1334
|
// status lines so content lands cleanly, and end the current phase (so the next phase's
|
|
1200
1335
|
// seconds count fresh, and any running command is considered finished).
|
|
@@ -1206,6 +1341,8 @@ async function runAgentTurn(history) {
|
|
|
1206
1341
|
// next step's blinking line starts from "Thinking", not last step's stale clause.
|
|
1207
1342
|
liveThought = "";
|
|
1208
1343
|
fenceThought = "";
|
|
1344
|
+
fullLiveThought = ""; // #96: the fuller thought is spent too…
|
|
1345
|
+
thoughtExpanded = false; // …and each new thinking phase starts collapsed.
|
|
1209
1346
|
lastContentAt = Date.now();
|
|
1210
1347
|
};
|
|
1211
1348
|
|
|
@@ -1264,6 +1401,7 @@ async function runAgentTurn(history) {
|
|
|
1264
1401
|
fenceThought += line + "\n";
|
|
1265
1402
|
const th = normalizeThought(fenceThought);
|
|
1266
1403
|
if (th) liveThought = th;
|
|
1404
|
+
fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
|
|
1267
1405
|
}
|
|
1268
1406
|
continue;
|
|
1269
1407
|
}
|
|
@@ -1403,6 +1541,7 @@ async function runAgentTurn(history) {
|
|
|
1403
1541
|
if (inStreamFence && fenceThought.length < 400) {
|
|
1404
1542
|
const th = normalizeThought(fenceThought + carry);
|
|
1405
1543
|
if (th) liveThought = th;
|
|
1544
|
+
fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
|
|
1406
1545
|
}
|
|
1407
1546
|
// A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
|
|
1408
1547
|
// a newline), so tokens are actively flowing — keep the ticker asleep.
|
|
@@ -1561,6 +1700,11 @@ async function runAgentTurn(history) {
|
|
|
1561
1700
|
} finally {
|
|
1562
1701
|
clearInterval(ticker);
|
|
1563
1702
|
clearStatus();
|
|
1703
|
+
// Task #96: stop mouse-reporting the moment the turn ends (also on cancel/error — this
|
|
1704
|
+
// finally always runs), and detach the click parser, so the user's shell selection is
|
|
1705
|
+
// never captured between turns.
|
|
1706
|
+
disableMouse();
|
|
1707
|
+
if (process.stdin.isTTY) process.stdin.removeListener("data", onMouseData);
|
|
1564
1708
|
following = false;
|
|
1565
1709
|
currentJobId = null;
|
|
1566
1710
|
// Drop anything typed (but not submitted) during the turn — echo was muted, so it's
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1077,6 +1077,35 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
|
|
|
1077
1077
|
raise RuntimeError(f"couldn't get a response ({_clip(str(last_err), 160)})")
|
|
1078
1078
|
|
|
1079
1079
|
|
|
1080
|
+
# A model-server call can fail for two very different reasons, and they deserve
|
|
1081
|
+
# different handling:
|
|
1082
|
+
# • the BACKEND is unavailable — an nginx 502/503/504 (the upstream Ollama/MLX box is
|
|
1083
|
+
# down, restarting, still loading the weights, or overloaded past the proxy's read
|
|
1084
|
+
# timeout), or the socket refused/timed out. These are INFRA outages: retrying a bit
|
|
1085
|
+
# harder can ride out a blip, and if it persists the honest thing is to SAY the
|
|
1086
|
+
# coding backend is down — never to hallucinate a generic answer as if we succeeded.
|
|
1087
|
+
# • a genuine model/agent error — worth the plain-reply degrade.
|
|
1088
|
+
# `str(e)` for a proxy error is the raw nginx HTML ("<html>…502 Bad Gateway…"), so match
|
|
1089
|
+
# on the status text/codes and the common connection-failure phrases.
|
|
1090
|
+
def _is_backend_unavailable(err) -> bool:
|
|
1091
|
+
s = str(err or "").lower()
|
|
1092
|
+
return (
|
|
1093
|
+
"502 bad gateway" in s
|
|
1094
|
+
or "503 service" in s
|
|
1095
|
+
or "504 gateway" in s
|
|
1096
|
+
or "bad gateway" in s
|
|
1097
|
+
or "gateway time-out" in s
|
|
1098
|
+
or "gateway timeout" in s
|
|
1099
|
+
or "connection refused" in s
|
|
1100
|
+
or "connection reset" in s
|
|
1101
|
+
or "connection aborted" in s
|
|
1102
|
+
or "failed to establish a new connection" in s
|
|
1103
|
+
or "max retries exceeded" in s
|
|
1104
|
+
or "read timed out" in s
|
|
1105
|
+
or "timed out" in s
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
|
|
1080
1109
|
def _synthesize_document(gathered: list, task: str, base_url: str, api_key: str,
|
|
1081
1110
|
model_id: str) -> str:
|
|
1082
1111
|
"""Turn raw gathered research observations into ONE clean, organized document body
|
|
@@ -1179,11 +1208,19 @@ def _normalize_code_tags(text: str) -> str:
|
|
|
1179
1208
|
return fixed
|
|
1180
1209
|
|
|
1181
1210
|
|
|
1182
|
-
def _escape_raw_newlines_in_strings(code: str) -> str:
|
|
1211
|
+
def _escape_raw_newlines_in_strings(code: str, defuse_tags: bool = False) -> str:
|
|
1183
1212
|
"""Turn RAW newlines/tabs that sit INSIDE a single/double-quoted string literal into
|
|
1184
1213
|
\\n/\\t escapes. Triple-quoted strings, comments, and everything outside a string are
|
|
1185
1214
|
copied verbatim. A tiny hand-rolled scanner (the code doesn't parse, so ast/tokenize
|
|
1186
|
-
can't help). Used
|
|
1215
|
+
can't help). Used by _repair_unterminated_string below.
|
|
1216
|
+
|
|
1217
|
+
defuse_tags: also rewrite a literal <code>/</code> that appears INSIDE a string literal
|
|
1218
|
+
(e.g. the file content the model is writing legitimately contains React's default
|
|
1219
|
+
"Edit <code>src/App.js</code>") to <co\\x64e>/</co\\x64e>. The `\\x64` is Python's hex
|
|
1220
|
+
escape for 'd', so at RUNTIME the string is still exactly "<code>"/"</code>" — but the
|
|
1221
|
+
literal source no longer contains the token smolagents uses as its code-block delimiter,
|
|
1222
|
+
so its parser can't mis-close the block on content. Only touches tags inside strings;
|
|
1223
|
+
the real closing </code> lives OUTSIDE any string and is left intact."""
|
|
1187
1224
|
out = []
|
|
1188
1225
|
i, n = 0, len(code)
|
|
1189
1226
|
quote = None # the delimiter of the string we're inside, or None
|
|
@@ -1196,7 +1233,10 @@ def _escape_raw_newlines_in_strings(code: str) -> str:
|
|
|
1196
1233
|
end = code.find(three, i + 3)
|
|
1197
1234
|
if end == -1:
|
|
1198
1235
|
out.append(code[i:]); break
|
|
1199
|
-
|
|
1236
|
+
seg = code[i:end + 3]
|
|
1237
|
+
if defuse_tags:
|
|
1238
|
+
seg = seg.replace("</code>", "</co\\x64e>").replace("<code>", "<co\\x64e>")
|
|
1239
|
+
out.append(seg); i = end + 3; continue
|
|
1200
1240
|
if ch == "#": # comment — copy to end of line
|
|
1201
1241
|
j = code.find("\n", i)
|
|
1202
1242
|
if j == -1:
|
|
@@ -1212,6 +1252,11 @@ def _escape_raw_newlines_in_strings(code: str) -> str:
|
|
|
1212
1252
|
out.append(ch); escaped = True; i += 1; continue
|
|
1213
1253
|
if ch == quote:
|
|
1214
1254
|
out.append(ch); quote = None; i += 1; continue
|
|
1255
|
+
# Defuse a delimiter-colliding tag INSIDE the string (see docstring).
|
|
1256
|
+
if defuse_tags and code.startswith("</code>", i):
|
|
1257
|
+
out.append("</co\\x64e>"); i += 7; continue
|
|
1258
|
+
if defuse_tags and code.startswith("<code>", i):
|
|
1259
|
+
out.append("<co\\x64e>"); i += 6; continue
|
|
1215
1260
|
out.append({"\n": "\\n", "\r": "\\r", "\t": "\\t"}.get(ch, ch)); i += 1
|
|
1216
1261
|
return "".join(out)
|
|
1217
1262
|
|
|
@@ -1242,15 +1287,42 @@ def _repair_unterminated_string(code: str) -> str:
|
|
|
1242
1287
|
def _repair_code_block_strings(text: str) -> str:
|
|
1243
1288
|
"""Apply _repair_unterminated_string to the python inside a <code>…</code> block
|
|
1244
1289
|
(leaving the surrounding Thought prose untouched). No-op when there's no block or the
|
|
1245
|
-
block already parses.
|
|
1246
|
-
|
|
1247
|
-
|
|
1290
|
+
block already parses.
|
|
1291
|
+
|
|
1292
|
+
Two-stage rescue:
|
|
1293
|
+
1. NON-GREEDY span (first <code> → first </code>) + raw-newline escaping — the common
|
|
1294
|
+
case (multi-line file content with unescaped newlines).
|
|
1295
|
+
2. If that still won't compile AND the content itself contains extra </code> tags
|
|
1296
|
+
(the model is writing a file whose CONTENT legitimately has <code>/</code> — e.g.
|
|
1297
|
+
React's default App.js "Edit <code>src/App.js</code>"), the non-greedy match closed
|
|
1298
|
+
the block early on that inner tag → a truncated, unterminatable fragment. Retry with
|
|
1299
|
+
the GREEDY span (first <code> → LAST </code>) and defuse the in-string tags so the
|
|
1300
|
+
only real </code> left is the true closer. This is the fix for the create_file loop
|
|
1301
|
+
where the coder burned minutes/100k+ tokens on 'unterminated string literal'."""
|
|
1302
|
+
om = re.search(r"<code>", text)
|
|
1303
|
+
if not om:
|
|
1248
1304
|
return text
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
if
|
|
1305
|
+
open_end = om.end()
|
|
1306
|
+
cm = re.search(r"</code>", text[open_end:])
|
|
1307
|
+
if not cm:
|
|
1252
1308
|
return text
|
|
1253
|
-
|
|
1309
|
+
close_start = open_end + cm.start()
|
|
1310
|
+
inner = text[open_end:close_start]
|
|
1311
|
+
fixed = _repair_unterminated_string(inner)
|
|
1312
|
+
if fixed != inner:
|
|
1313
|
+
return text[:open_end] + fixed + text[close_start:]
|
|
1314
|
+
# Stage 2 — only worth trying when there's MORE than one </code> (extra tags in content).
|
|
1315
|
+
last = text.rfind("</code>")
|
|
1316
|
+
if last <= close_start:
|
|
1317
|
+
return text # single </code>; nothing a greedy re-read would change
|
|
1318
|
+
greedy = text[open_end:last]
|
|
1319
|
+
sanitized = _escape_raw_newlines_in_strings(greedy, defuse_tags=True)
|
|
1320
|
+
try:
|
|
1321
|
+
compile(sanitized, "<gonext-code>", "exec")
|
|
1322
|
+
except Exception: # noqa: BLE001
|
|
1323
|
+
return text # still not parseable — leave it for the loop-breaker / model retry
|
|
1324
|
+
# Rebuild so the FIRST literal </code> is now the true closer (inner ones are defused).
|
|
1325
|
+
return text[:open_end] + sanitized + text[last:]
|
|
1254
1326
|
|
|
1255
1327
|
|
|
1256
1328
|
def _strip_think(text: str) -> str:
|
|
@@ -4173,6 +4245,12 @@ def run_agent_chat(cfg):
|
|
|
4173
4245
|
# Largest SINGLE-request prompt-token count seen this turn (task #84) — the
|
|
4174
4246
|
# peak context size, not the sum. The REPL keeps a per-workspace max of this.
|
|
4175
4247
|
self._code_tokens_max = 0
|
|
4248
|
+
# Loop-breaker (#95 B1): how many CONSECUTIVE generations have produced a code
|
|
4249
|
+
# block that STILL won't compile after our repair. A weak coder can get stuck
|
|
4250
|
+
# re-emitting the same unparseable tool call (seen live: create_file with a
|
|
4251
|
+
# multi-line string, ~10 identical retries, 863s/133k tokens). After a few in a
|
|
4252
|
+
# row we stop feeding smolagents an un-runnable block and finish honestly.
|
|
4253
|
+
self._parse_fail_streak = 0
|
|
4176
4254
|
# stream_agent: request the completion with stream=True and reassemble the
|
|
4177
4255
|
# deltas into one message. Enabled for Ollama, where a long single-shot
|
|
4178
4256
|
# (non-stream) generation sends nothing until the very end — a reverse proxy
|
|
@@ -4445,9 +4523,16 @@ def run_agent_chat(cfg):
|
|
|
4445
4523
|
hb.start()
|
|
4446
4524
|
last_err = None
|
|
4447
4525
|
try:
|
|
4448
|
-
|
|
4526
|
+
# A gateway outage (502/503/504, refused/reset/timeout) is transient infra —
|
|
4527
|
+
# the remote box may just be cold or the proxy briefly down — so give it MORE
|
|
4528
|
+
# attempts and a longer backoff to ride out a blip, instead of the 3 quick
|
|
4529
|
+
# tries a normal error gets. The attempt count is decided per-error: we start
|
|
4530
|
+
# with the generic budget and extend it the moment we see a gateway error.
|
|
4531
|
+
attempt, max_attempts = 0, 3
|
|
4532
|
+
while attempt < max_attempts:
|
|
4449
4533
|
try:
|
|
4450
4534
|
msg = self._generate_once(*args, **kwargs)
|
|
4535
|
+
last_err = None
|
|
4451
4536
|
break
|
|
4452
4537
|
except Exception as e: # noqa: BLE001
|
|
4453
4538
|
emsg = str(e)
|
|
@@ -4465,10 +4550,19 @@ def run_agent_chat(cfg):
|
|
|
4465
4550
|
"names (for Ollama use the tag, e.g. qwen3:14b)."
|
|
4466
4551
|
) from e
|
|
4467
4552
|
last_err = e
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4553
|
+
gateway = _is_backend_unavailable(e)
|
|
4554
|
+
if gateway and max_attempts < 6:
|
|
4555
|
+
max_attempts = 6 # stretch the budget for a flaky backend
|
|
4556
|
+
attempt += 1
|
|
4557
|
+
if attempt < max_attempts:
|
|
4558
|
+
# Longer, capped backoff for gateway errors (up to ~8s) so a
|
|
4559
|
+
# cold/loading model has time to come back; short for the rest.
|
|
4560
|
+
delay = min(8.0, 2.0 * attempt) if gateway else 1.5 * attempt
|
|
4561
|
+
kind = "gateway/backend unavailable" if gateway else "transient"
|
|
4562
|
+
_log(f"generate {kind} error (attempt {attempt}/{max_attempts}): "
|
|
4563
|
+
f"{_clip(emsg, 160)} — retrying in {delay:.0f}s")
|
|
4564
|
+
time.sleep(delay)
|
|
4565
|
+
if last_err is not None:
|
|
4472
4566
|
raise last_err
|
|
4473
4567
|
finally:
|
|
4474
4568
|
hb_stop.set()
|
|
@@ -4532,7 +4626,42 @@ def run_agent_chat(cfg):
|
|
|
4532
4626
|
_log("repaired unterminated string literal in tool-call code")
|
|
4533
4627
|
if repaired != content:
|
|
4534
4628
|
msg.content = repaired
|
|
4629
|
+
# Loop-breaker (#95 B1): if the block STILL won't compile after the
|
|
4630
|
+
# repair, the model is stuck emitting an unparseable tool call. Count
|
|
4631
|
+
# consecutive failures; after a few, finish honestly rather than burn
|
|
4632
|
+
# the whole step budget — each retry is a full (often 150s+) model
|
|
4633
|
+
# call on an ever-growing context. B2's greedy/defuse fix means the
|
|
4634
|
+
# <code>-in-content collision now compiles here, so this only trips on
|
|
4635
|
+
# genuinely un-fixable code (e.g. a truncated generation).
|
|
4636
|
+
_cb = re.search(r"<code>([\s\S]*?)</code>", msg.content or "")
|
|
4637
|
+
_still_bad = False
|
|
4638
|
+
if _cb:
|
|
4639
|
+
try:
|
|
4640
|
+
compile(_cb.group(1), "<gonext-code>", "exec")
|
|
4641
|
+
except Exception: # noqa: BLE001
|
|
4642
|
+
_still_bad = True
|
|
4643
|
+
if _still_bad:
|
|
4644
|
+
self._parse_fail_streak += 1
|
|
4645
|
+
_log("tool-call code still unparseable after repair "
|
|
4646
|
+
f"(streak {self._parse_fail_streak}/3)")
|
|
4647
|
+
if self._parse_fail_streak >= 3:
|
|
4648
|
+
_log("parse-fail streak hit limit → ending the turn honestly "
|
|
4649
|
+
"instead of looping on an unparseable tool call")
|
|
4650
|
+
_stuck_msg = (
|
|
4651
|
+
"I got stuck on a code-formatting error: my edit to the "
|
|
4652
|
+
"file kept failing to parse (a string/escaping issue in the "
|
|
4653
|
+
"tool call), so I stopped instead of retrying in a loop. "
|
|
4654
|
+
"Any dev server I started is still running. Please try "
|
|
4655
|
+
"again, or tell me the specific change to make and I'll "
|
|
4656
|
+
"keep it to a small, single-line edit."
|
|
4657
|
+
)
|
|
4658
|
+
msg.content = ("<code>\nfinal_answer("
|
|
4659
|
+
+ repr(_stuck_msg) + ")\n</code>")
|
|
4660
|
+
self._parse_fail_streak = 0
|
|
4661
|
+
else:
|
|
4662
|
+
self._parse_fail_streak = 0
|
|
4535
4663
|
else:
|
|
4664
|
+
self._parse_fail_streak = 0 # a prose turn isn't a parse failure
|
|
4536
4665
|
stripped = content.strip()
|
|
4537
4666
|
looks_final = (
|
|
4538
4667
|
len(stripped) >= 400 or re.search(
|
|
@@ -5778,6 +5907,26 @@ def run_agent_chat(cfg):
|
|
|
5778
5907
|
_log(f"agent config error (not degrading): {cfg_err}")
|
|
5779
5908
|
_emit({"type": "final", "text": f"⚠️ {cfg_err}"})
|
|
5780
5909
|
return
|
|
5910
|
+
# Coding-model BACKEND outage (502/503/504, refused/timeout): the tool-capable
|
|
5911
|
+
# loop never got to run because its code model was unreachable. Do NOT fall through
|
|
5912
|
+
# to _plain_reply — a bare chat model would stream a confident GENERIC how-to (e.g.
|
|
5913
|
+
# "to create a React app, run npx create-react-app…") that looks like a real answer
|
|
5914
|
+
# but did NOTHING the user asked (seen live: "create a reactjs and start it" → the
|
|
5915
|
+
# coder 502'd 3× → generic instructions, no warning). That's worse than an honest
|
|
5916
|
+
# failure: it hides the outage and, being persisted to history, misleads the next
|
|
5917
|
+
# turn. Emit a clear, standalone "backend is down, retry" and stop — the checkpoint
|
|
5918
|
+
# is kept (not cleared) so a later "continue" resumes once the box is back.
|
|
5919
|
+
if _is_backend_unavailable(e):
|
|
5920
|
+
_log(f"agent error (coding-model backend unavailable, NOT degrading): {_clip(str(e), 160)}")
|
|
5921
|
+
_emit({"type": "final", "text": (
|
|
5922
|
+
"⚠️ The coding model backend is currently unavailable (the server returned "
|
|
5923
|
+
"a gateway error / timed out), so I couldn't run the tools to do this. It's "
|
|
5924
|
+
"usually starting up, reloading the model, or briefly overloaded — not a "
|
|
5925
|
+
"problem with your request. Please try again in a moment; if it keeps "
|
|
5926
|
+
"happening, check that the coding model server is up. (I didn't make any "
|
|
5927
|
+
"changes.)"
|
|
5928
|
+
)})
|
|
5929
|
+
return
|
|
5781
5930
|
# An unexpected failure inside the agent loop should still return a useful
|
|
5782
5931
|
# answer from the conversation rather than surfacing a raw error to the user —
|
|
5783
5932
|
# but only when the fallback ACTUALLY has something useful to say.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.262",
|
|
4
4
|
"description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|