@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 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
- if (prose && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
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 word
1136
- // under it. Cursor sits at the end of line 2, so clearing = wipe line 2, move up, wipe
1137
- // line 1, leaving the cursor back at the status's origin for a redraw or real content.
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
- process.stdout.write("\r\x1b[K\x1b[1A\r\x1b[K");
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
- process.stdout.write(
1281
+ let out =
1189
1282
  `${green(BULLET)} ${green(`${fit(primary)}… (${secs}s)`)}` +
1190
- "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`
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
@@ -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 only by _repair_unterminated_string below."""
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
- out.append(code[i:end + 3]); i = end + 3; continue
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
- m = re.search(r"<code>([\s\S]*?)</code>", text)
1247
- if not m:
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
- inner = m.group(1)
1250
- fixed = _repair_unterminated_string(inner)
1251
- if fixed == inner:
1305
+ open_end = om.end()
1306
+ cm = re.search(r"</code>", text[open_end:])
1307
+ if not cm:
1252
1308
  return text
1253
- return text[:m.start(1)] + fixed + text[m.end(1):]
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
- for attempt in range(3):
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
- if attempt < 2:
4469
- _log(f"generate transient error (attempt {attempt + 1}/3): {e} — retrying")
4470
- time.sleep(1.5 * (attempt + 1))
4471
- else:
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.260",
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",