@tiens.nguyen/gonext-local-worker 1.0.260 → 1.0.261

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
@@ -1025,13 +1025,20 @@ const normalizeThought = (buf) => {
1025
1025
  const t = String(buf || "");
1026
1026
  const ci = t.search(/<code[\s>]/i);
1027
1027
  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.
1028
+ // 1) a genuine Thought prose line (before the code) — only if it isn't itself code AND
1029
+ // reads like an actual clause. A streaming token boundary can leave the first
1030
+ // non-empty line as a single bare word (e.g. "string", when the model is rambling
1031
+ // about "multi-line strings"); shown alone on the blinking line it's meaningless and,
1032
+ // because liveThought only updates on a truthy value, it can FREEZE there through a
1033
+ // long silent prompt-eval ("string… (777s)"). Require ≥2 words and a little length so
1034
+ // a lone fragment falls through to the tool label / "Thinking…" instead.
1029
1035
  const prose = head
1030
1036
  .replace(/^\s*Thought:\s*/i, "")
1031
1037
  .split(/\n/)
1032
1038
  .map((x) => x.trim())
1033
1039
  .find(Boolean);
1034
- if (prose && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
1040
+ const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
1041
+ if (proseIsClause && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
1035
1042
  // 2) otherwise name the tool being called (from the code part).
1036
1043
  const codePart = ci >= 0 ? t.slice(ci) : t;
1037
1044
  const m = /(?:<code>\s*)?\b([a-z_]\w*)\s*\(\s*(?:path\s*=\s*)?["']?([^"'\n,)]*)/i.exec(codePart);
@@ -1117,6 +1124,7 @@ async function runAgentTurn(history) {
1117
1124
  // "◐ working… (7s)" — instead of dumping the model's rambling reasoning and stamping a
1118
1125
  // "✔ completed thought" on every token-stream micro-pause (the reported clutter).
1119
1126
  const QUIET_MS = 700; // stream idle this long ⇒ we're waiting on the model
1127
+ const STALE_THOUGHT_MS = 25000; // no new tokens this long ⇒ the current Thought clause is stale (#95 A)
1120
1128
  let lastContentAt = Date.now();
1121
1129
  let thinking = false;
1122
1130
  let thinkingSince = 0;
@@ -1154,6 +1162,11 @@ async function runAgentTurn(history) {
1154
1162
  thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); lastWordBucket = 0;
1155
1163
  }
1156
1164
  const now = Date.now();
1165
+ // Age out a stale Thought clause: if the model has produced NO new stream content for a
1166
+ // while (e.g. a 150s+ silent prompt-eval on a huge context), the last clause is no longer
1167
+ // "what it's doing now" — drop it so the line falls back to the playful word / "Thinking…"
1168
+ // instead of freezing on an old thought while the timer climbs ("string… (777s)", #95 A).
1169
+ if (liveThought && now - lastContentAt > STALE_THOUGHT_MS) liveThought = "";
1157
1170
  const secs = thinkSecs();
1158
1171
  // Rotate the playful word once per 12s window (guarded so the fast ticker doesn't
1159
1172
  // re-roll it many times within the same second).
@@ -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.261",
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",