@tiens.nguyen/gonext-local-worker 1.0.320 → 1.0.323

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
@@ -1779,9 +1779,21 @@ async function runAgentTurn(history) {
1779
1779
  continue;
1780
1780
  }
1781
1781
  if (line.trim() === "") {
1782
- // Several sources (swallowed routing lines, swallowed step summaries) each leave
1783
- // their own blank-line companion behind collapse consecutive blanks into one.
1784
- if (!thinking && !statusShown && !lastWasBlank) {
1782
+ // Vertical rhythm (task #115). Blank lines arrive from the worker unconditionally —
1783
+ // every {"type":"step"} chunk is `text + "\n\n"`, and closeStreamFence() adds its
1784
+ // own — but whether one was PRINTED used to depend on `!thinking && !statusShown`,
1785
+ // i.e. on whether the status ticker happened to have redrawn its two-line block in
1786
+ // the last ~120ms. Identical output rendered two different ways (reported live: the
1787
+ // first ● bullet flush against the prompt, the next three each with a gap above).
1788
+ // Spacing must not be a race, so neither term is consulted any more:
1789
+ // • ACTION stream → swallow every stream-borne blank. Bullets and edit cards form
1790
+ // ONE tight list (task #41); the blank line before the final answer is written
1791
+ // by the CALLER (`console.log(shown ? "" : "\n\n" + answer)`), not here.
1792
+ // • PLAIN-REPLY answer → this text IS the answer, so its blanks are real
1793
+ // paragraph breaks. Keep them (collapsing runs) and clear the status block
1794
+ // first, which is what the old `!statusShown` guard was really protecting.
1795
+ if (plainReplyFlow && !lastWasBlank) {
1796
+ onContent();
1785
1797
  process.stdout.write("\n");
1786
1798
  lastWasBlank = true;
1787
1799
  }
@@ -25,6 +25,7 @@ Emits NDJSON lines on stdout:
25
25
  """
26
26
  import contextlib
27
27
  import json
28
+ import random
28
29
  import re
29
30
  import sys
30
31
  import threading
@@ -820,6 +821,22 @@ class _AgentConfigError(RuntimeError):
820
821
  worth a plain-reply degrade — the user needs the message itself to fix Settings."""
821
822
 
822
823
 
824
+ class _AgentBackendOverloaded(RuntimeError):
825
+ """The coding backend refused every attempt with 429/rate-limit (task #114). Carries
826
+ the user-facing explanation; like a gateway outage it must NOT degrade to a plain
827
+ reply, which would answer from the chat model as if the work had been done."""
828
+
829
+
830
+ def _host_of(url) -> str:
831
+ """Just the hostname of a base URL ('https://api.moonshot.ai/v1' → 'api.moonshot.ai'),
832
+ for user-facing messages. Falls back to the raw string."""
833
+ try:
834
+ from urllib.parse import urlparse
835
+ return urlparse(str(url or "")).netloc or str(url or "")
836
+ except Exception: # noqa: BLE001
837
+ return str(url or "")
838
+
839
+
823
840
  def _list_model_ids(base_url, api_key=""):
824
841
  """Return the model ids an OpenAI-compatible server reports at {base_url}/models.
825
842
 
@@ -1452,8 +1469,92 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
1452
1469
  # • a genuine model/agent error — worth the plain-reply degrade.
1453
1470
  # `str(e)` for a proxy error is the raw nginx HTML ("<html>…502 Bad Gateway…"), so match
1454
1471
  # on the status text/codes and the common connection-failure phrases.
1472
+ def _is_backend_overloaded(err) -> bool:
1473
+ """True when the model server ACCEPTED the connection but REFUSED the work because it
1474
+ is saturated or we are over a quota — 429 / engine_overloaded / rate limit (task #114).
1475
+
1476
+ Deliberately separate from _is_backend_unavailable: that one means "the box or proxy
1477
+ is not answering" (retry the same request, it will probably land), while this means
1478
+ "the server is telling us to slow down" (re-sending immediately makes it worse and can
1479
+ deepen the limit). The two get different retry budgets and different user-facing copy.
1480
+
1481
+ Live trigger: Moonshot returned
1482
+ Error code: 429 - {'error': {'message': 'The engine is currently overloaded, please
1483
+ try again later', 'type': 'engine_overloaded_error'}}
1484
+ A local Ollama/MLX box does not produce these, so nothing keyed off this fires there."""
1485
+ s = str(err or "").lower()
1486
+ if "429" in s or "too many requests" in s:
1487
+ return True
1488
+ return (
1489
+ "engine_overloaded" in s
1490
+ or "overloaded" in s
1491
+ or "rate limit" in s
1492
+ or "rate_limit" in s
1493
+ # OpenAI's billing/quota code. NOT a bare "quota" — a tool hitting "Disk quota
1494
+ # exceeded" would otherwise be reported to the user as a model overload.
1495
+ or "insufficient_quota" in s
1496
+ or "over capacity" in s
1497
+ or "at capacity" in s
1498
+ )
1499
+
1500
+
1501
+ def _retry_after_seconds(err):
1502
+ """The provider's Retry-After hint in seconds, or None. Reads the header off an
1503
+ openai-python APIStatusError (`err.response.headers`) and falls back to a
1504
+ 'retry after 12 seconds' phrase in the message. Values are clamped to a sane range so
1505
+ a bogus header can't park the turn for an hour."""
1506
+ val = None
1507
+ resp = getattr(err, "response", None)
1508
+ headers = getattr(resp, "headers", None)
1509
+ if headers is not None:
1510
+ try:
1511
+ val = headers.get("retry-after") or headers.get("Retry-After")
1512
+ except Exception: # noqa: BLE001
1513
+ val = None
1514
+ if val is None:
1515
+ m = re.search(r"retry[\s-]?after[\s:]+(\d+(?:\.\d+)?)", str(err or ""), re.I)
1516
+ val = m.group(1) if m else None
1517
+ if val is None:
1518
+ return None
1519
+ try:
1520
+ secs = float(str(val).strip())
1521
+ except (TypeError, ValueError):
1522
+ return None # HTTP-date form — not worth parsing; fall back to our own backoff
1523
+ if secs <= 0:
1524
+ return None
1525
+ return max(1.0, min(60.0, secs))
1526
+
1527
+
1528
+ def _retry_delay(attempt: int, overloaded: bool, gateway: bool, err=None) -> float:
1529
+ """Seconds to wait before retry #attempt (1-based). Pure, so the policy can be
1530
+ replayed offline (task #114).
1531
+
1532
+ overloaded (429) — PATIENT and jittered: ~5s → 10s → 20s → 30s (capped), plus up to
1533
+ 25% jitter so parallel workers don't retry in lock-step. The
1534
+ provider's own Retry-After wins outright when it sends one.
1535
+ gateway (502…) — unchanged from #94: prompt re-send, 2s/4s/6s/8s capped at 8.
1536
+ otherwise — unchanged: 1.5s per attempt.
1537
+
1538
+ Only the `overloaded` branch is new; the other two must stay byte-for-byte, since they
1539
+ are the path an Ollama/MLX coder takes."""
1540
+ if overloaded:
1541
+ hinted = _retry_after_seconds(err)
1542
+ if hinted:
1543
+ return hinted
1544
+ delay = min(30.0, 5.0 * (2 ** (attempt - 1)))
1545
+ return delay + random.uniform(0, min(3.0, delay * 0.25))
1546
+ if gateway:
1547
+ return min(8.0, 2.0 * attempt)
1548
+ return 1.5 * attempt
1549
+
1550
+
1455
1551
  def _is_backend_unavailable(err) -> bool:
1456
1552
  s = str(err or "").lower()
1553
+ # An overload (429) is its own class — never let it fall into the gateway bucket, whose
1554
+ # policy (re-send promptly, up to 6 times) is exactly wrong for a server saying "slow
1555
+ # down". Checked FIRST because a 429 body can also contain the word "timeout".
1556
+ if _is_backend_overloaded(err):
1557
+ return False
1457
1558
  return (
1458
1559
  "502 bad gateway" in s
1459
1560
  or "503 service" in s
@@ -5164,16 +5265,31 @@ def run_agent_chat(cfg):
5164
5265
 
5165
5266
  def _generate_once(self, *args, **kwargs):
5166
5267
  """One model call. Streams + reassembles when _stream_agent, else the normal
5167
- non-streaming path. Streaming is a pure transport optimization: on ANY failure
5168
- it falls back to the non-streaming call, and it's skipped for tool-calling
5169
- mode (tools_to_call_from), whose tool_call deltas we don't reassemble."""
5268
+ non-streaming path. Streaming is a pure transport optimization: on a TRANSPORT
5269
+ failure it falls back to the non-streaming call, and it's skipped for
5270
+ tool-calling mode (tools_to_call_from), whose tool_call deltas we don't
5271
+ reassemble.
5272
+
5273
+ Exception (task #114): an OVERLOAD (429 / engine_overloaded / rate limit) is
5274
+ NOT a transport failure — the server refused the work. Re-sending the identical
5275
+ request unstreamed, instantly and with no backoff, is the worst possible answer:
5276
+ it can deepen the limit, and because a non-streamed call emits no first token it
5277
+ then sits silent behind the 600s client timeout (live: 167s of "Thinking…" until
5278
+ the user pressed Ctrl+C). Re-raise instead, so generate()'s retry loop applies a
5279
+ real backoff and tells the user what is going on."""
5170
5280
  tools_to_call_from = kwargs.get("tools_to_call_from")
5171
5281
  if not self._stream_agent or tools_to_call_from:
5172
5282
  return super().generate(*args, **kwargs)
5173
5283
  try:
5174
5284
  return self._streamed_generate(*args, **kwargs)
5175
5285
  except Exception as e: # noqa: BLE001
5286
+ if _is_backend_overloaded(e):
5287
+ _log(f"streamed generate refused ({_clip(str(e), 200)}) → NOT falling "
5288
+ "back to non-streaming; letting the retry loop back off (#114)")
5289
+ raise
5176
5290
  _log(f"streamed generate failed ({e}) → falling back to non-streaming")
5291
+ _emit({"type": "step",
5292
+ "text": "Streaming unavailable — retrying without streaming…"})
5177
5293
  return super().generate(*args, **kwargs)
5178
5294
 
5179
5295
  def _run_stream(self, completion_kwargs):
@@ -5505,18 +5621,45 @@ def run_agent_chat(cfg):
5505
5621
  "names (for Ollama use the tag, e.g. qwen3:14b)."
5506
5622
  ) from e
5507
5623
  last_err = e
5508
- gateway = _is_backend_unavailable(e)
5509
- if gateway and max_attempts < 6:
5624
+ # An OVERLOAD (429/rate limit, task #114) is its own class: the
5625
+ # server answered and told us to slow down, so it needs a PATIENT,
5626
+ # jittered backoff rather than the gateway policy's prompt re-send.
5627
+ # Checked first — _is_backend_unavailable already excludes it, but
5628
+ # the ordering keeps the intent obvious.
5629
+ overloaded = _is_backend_overloaded(e)
5630
+ gateway = (not overloaded) and _is_backend_unavailable(e)
5631
+ if (overloaded or gateway) and max_attempts < 6:
5510
5632
  max_attempts = 6 # stretch the budget for a flaky backend
5511
5633
  attempt += 1
5512
5634
  if attempt < max_attempts:
5513
5635
  # Longer, capped backoff for gateway errors (up to ~8s) so a
5514
- # cold/loading model has time to come back; short for the rest.
5515
- delay = min(8.0, 2.0 * attempt) if gateway else 1.5 * attempt
5636
+ # cold/loading model has time to come back; short for the rest;
5637
+ # patient + jittered for an overload. See _retry_delay.
5638
+ delay = _retry_delay(attempt, overloaded, gateway, e)
5516
5639
  kind = "gateway/backend unavailable" if gateway else "transient"
5640
+ if overloaded:
5641
+ kind = "backend overloaded (429/rate limit)"
5642
+ # SURFACE IT: without this the REPL keeps showing a playful
5643
+ # heartbeat word and the user has no idea the provider is
5644
+ # refusing work (live: 167s of "Thinking…" → Ctrl+C).
5645
+ _emit({"type": "step",
5646
+ "text": "Coding model is overloaded (429) — waiting "
5647
+ f"{delay:.0f}s, then retrying "
5648
+ f"(attempt {attempt + 1}/{max_attempts})…"})
5517
5649
  _log(f"generate {kind} error (attempt {attempt}/{max_attempts}): "
5518
5650
  f"{_clip(emsg, 160)} — retrying in {delay:.0f}s")
5519
5651
  time.sleep(delay)
5652
+ elif overloaded:
5653
+ # Out of attempts: end the turn with a cause the user can act on
5654
+ # instead of a bare stack trace (same spirit as #94's 502 copy).
5655
+ _log("backend overloaded after all attempts → failing honestly")
5656
+ raise _AgentBackendOverloaded(
5657
+ "The agent coding model backend "
5658
+ f"({_host_of(coding_base_url)}) is overloaded right now and "
5659
+ f"refused {max_attempts} attempts (HTTP 429). Nothing ran. "
5660
+ "Please try again in a minute, or switch the coding backend "
5661
+ "in Settings → Agent."
5662
+ ) from e
5520
5663
  if last_err is not None:
5521
5664
  raise last_err
5522
5665
  finally:
@@ -7010,6 +7153,29 @@ def run_agent_chat(cfg):
7010
7153
  # failure: it hides the outage and, being persisted to history, misleads the next
7011
7154
  # turn. Emit a clear, standalone "backend is down, retry" and stop — the checkpoint
7012
7155
  # is kept (not cleared) so a later "continue" resumes once the box is back.
7156
+ # Coding-model OVERLOAD (429/rate limit, task #114): same reasoning as the outage
7157
+ # branch below — the tools never ran, so degrading to _plain_reply would invent a
7158
+ # confident how-to for work that didn't happen. Reported separately because the
7159
+ # cause and the advice differ: the server is UP, it's just saturated or we're over
7160
+ # quota, so "try again shortly" is the honest fix. Checked BEFORE the outage branch
7161
+ # (_is_backend_unavailable deliberately excludes overloads).
7162
+ if _is_backend_overloaded(e):
7163
+ _log(f"agent error (coding-model backend OVERLOADED, NOT degrading): {_clip(str(e), 160)}")
7164
+ _ov = None
7165
+ _cur, _depth = e, 0
7166
+ while _cur is not None and _depth < 6:
7167
+ if isinstance(_cur, _AgentBackendOverloaded):
7168
+ _ov = _cur
7169
+ break
7170
+ _cur, _depth = (getattr(_cur, "__cause__", None)
7171
+ or getattr(_cur, "__context__", None)), _depth + 1
7172
+ _emit({"type": "final", "text": "⚠️ " + (str(_ov) if _ov else (
7173
+ "The coding model backend is overloaded right now (HTTP 429 — it asked me "
7174
+ "to slow down), so I couldn't run the tools to do this. The server is up, "
7175
+ "it's just saturated or over quota. Please try again in a minute, or switch "
7176
+ "the coding backend in Settings → Agent. (I didn't make any changes.)"
7177
+ ))})
7178
+ return
7013
7179
  if _is_backend_unavailable(e):
7014
7180
  _log(f"agent error (coding-model backend unavailable, NOT degrading): {_clip(str(e), 160)}")
7015
7181
  _emit({"type": "final", "text": (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.320",
3
+ "version": "1.0.323",
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",