@tiens.nguyen/gonext-local-worker 1.0.320 → 1.0.322
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_agent_chat.py +173 -7
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
5168
|
-
it falls back to the non-streaming call, and it's skipped for
|
|
5169
|
-
mode (tools_to_call_from), whose tool_call deltas we don't
|
|
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
|
-
|
|
5509
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.322",
|
|
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",
|