@tiens.nguyen/gonext-local-worker 1.0.134 → 1.0.138
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-local-worker.mjs +8 -6
- package/gonext_agent_chat.py +113 -26
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1526,12 +1526,14 @@ async function runAgentChatJob(job) {
|
|
|
1526
1526
|
emailFrom: payload?.emailFrom ?? "",
|
|
1527
1527
|
emailAllowList: payload?.emailAllowList ?? "",
|
|
1528
1528
|
});
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
// client's hard WS deadline (
|
|
1532
|
-
// instead of a generic WS timeout. On expiry the python child is
|
|
1533
|
-
// also shows up as a BrokenPipeError in the
|
|
1534
|
-
|
|
1529
|
+
// 10 min max for an agent run: 5 ReAct steps on a 14B with a cold prompt cache —
|
|
1530
|
+
// or a remote Ollama box cold-loading a big model — can exceed the old caps. Must
|
|
1531
|
+
// stay BELOW the web client's hard WS deadline (660s) so our clean timeout error
|
|
1532
|
+
// reaches the user instead of a generic WS timeout. On expiry the python child is
|
|
1533
|
+
// SIGKILLed, which also shows up as a BrokenPipeError in the model server log
|
|
1534
|
+
// (expected). Liveness during long model calls comes from the python heartbeat
|
|
1535
|
+
// (a keepalive step chunk every 45s), not from this cap.
|
|
1536
|
+
const timeoutMs = 600_000;
|
|
1535
1537
|
|
|
1536
1538
|
let inThink = false;
|
|
1537
1539
|
let finalText = "";
|
package/gonext_agent_chat.py
CHANGED
|
@@ -23,6 +23,7 @@ import contextlib
|
|
|
23
23
|
import json
|
|
24
24
|
import re
|
|
25
25
|
import sys
|
|
26
|
+
import threading
|
|
26
27
|
import time
|
|
27
28
|
import traceback
|
|
28
29
|
import urllib.request
|
|
@@ -368,12 +369,16 @@ def _web_search_impl(query):
|
|
|
368
369
|
)
|
|
369
370
|
|
|
370
371
|
|
|
371
|
-
|
|
372
|
-
"""
|
|
372
|
+
class _AgentConfigError(RuntimeError):
|
|
373
|
+
"""Deterministic setup error (e.g. wrong model name/URL). Not retryable and not
|
|
374
|
+
worth a plain-reply degrade — the user needs the message itself to fix Settings."""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _list_model_ids(base_url, api_key=""):
|
|
378
|
+
"""Return the model ids an OpenAI-compatible server reports at {base_url}/models.
|
|
373
379
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
fall back. Used when the user supplies a coding-model URL but no model name.
|
|
380
|
+
`base_url` already ends with /v1. Returns [] on any failure so callers can
|
|
381
|
+
fall back.
|
|
377
382
|
"""
|
|
378
383
|
url = base_url.rstrip("/") + "/models"
|
|
379
384
|
headers = {"Accept": "application/json"}
|
|
@@ -384,14 +389,31 @@ def _detect_model_id(base_url, api_key=""):
|
|
|
384
389
|
with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
|
|
385
390
|
payload = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
386
391
|
except Exception as e: # noqa: BLE001
|
|
387
|
-
_log(f"model
|
|
388
|
-
return
|
|
392
|
+
_log(f"model list failed {url}: {e}")
|
|
393
|
+
return []
|
|
389
394
|
data = payload.get("data") if isinstance(payload, dict) else None
|
|
390
|
-
if isinstance(data, list)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
+
if not isinstance(data, list):
|
|
396
|
+
return []
|
|
397
|
+
return [d["id"].strip() for d in data
|
|
398
|
+
if isinstance(d, dict) and isinstance(d.get("id"), str) and d["id"].strip()]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _detect_model_id(base_url, api_key=""):
|
|
402
|
+
"""Ask an OpenAI-compatible server which model it serves (first reported id).
|
|
403
|
+
|
|
404
|
+
Returns "" on any failure so callers can fall back. Used when the user supplies
|
|
405
|
+
a coding-model URL but no model name.
|
|
406
|
+
"""
|
|
407
|
+
ids = _list_model_ids(base_url, api_key)
|
|
408
|
+
if not ids:
|
|
409
|
+
return ""
|
|
410
|
+
if len(ids) > 1:
|
|
411
|
+
# Ollama servers list every pulled model; "first" is arbitrary and may be a
|
|
412
|
+
# huge/slow one (seen live: picked gemma4:31b over qwen3:14b).
|
|
413
|
+
_log(f"model detect: server hosts {len(ids)} models {ids} — using "
|
|
414
|
+
f"{ids[0]!r}. Set the coding model NAME in Settings to choose "
|
|
415
|
+
"a specific one.")
|
|
416
|
+
return ids[0]
|
|
395
417
|
|
|
396
418
|
|
|
397
419
|
def _summarise_step(step_log):
|
|
@@ -1750,18 +1772,52 @@ def run_agent_chat(cfg):
|
|
|
1750
1772
|
# A transient "Connection error" to the local MLX server (seen mid-loop when the
|
|
1751
1773
|
# context grows large) otherwise aborts the whole run — retry a couple of times
|
|
1752
1774
|
# with a short backoff before giving up.
|
|
1775
|
+
# Heartbeat: while a model call is in flight the agent emits NOTHING, and
|
|
1776
|
+
# the web's no-progress watchdog kills the chat. A big/cold model (e.g. a
|
|
1777
|
+
# 31B on a remote Ollama box) can take minutes on prompt-eval before the
|
|
1778
|
+
# first byte — emit a keepalive step every 45s until the call returns.
|
|
1779
|
+
hb_stop = threading.Event()
|
|
1780
|
+
|
|
1781
|
+
def _heartbeat():
|
|
1782
|
+
waited = 0
|
|
1783
|
+
while not hb_stop.wait(45):
|
|
1784
|
+
waited += 45
|
|
1785
|
+
_emit({"type": "step",
|
|
1786
|
+
"text": f"Model is still working… ({waited}s — a large or "
|
|
1787
|
+
"cold-loading model can take a few minutes)"})
|
|
1788
|
+
_log(f"heartbeat: model call in flight {waited}s")
|
|
1789
|
+
|
|
1790
|
+
hb = threading.Thread(target=_heartbeat, daemon=True)
|
|
1791
|
+
hb.start()
|
|
1753
1792
|
last_err = None
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1793
|
+
try:
|
|
1794
|
+
for attempt in range(3):
|
|
1795
|
+
try:
|
|
1796
|
+
msg = super().generate(*args, **kwargs)
|
|
1797
|
+
break
|
|
1798
|
+
except Exception as e: # noqa: BLE001
|
|
1799
|
+
emsg = str(e)
|
|
1800
|
+
# A 404 "model not found" is a config error (wrong model NAME
|
|
1801
|
+
# for this server, e.g. an MLX-style name against Ollama) —
|
|
1802
|
+
# deterministic, so retrying is pointless. Fail fast with the
|
|
1803
|
+
# server's actual model list so the user can fix Settings.
|
|
1804
|
+
if "404" in emsg and "not found" in emsg.lower():
|
|
1805
|
+
ids = _list_model_ids(coding_base_url, agent_api_key)
|
|
1806
|
+
have = f" This server has: {', '.join(ids)}." if ids else ""
|
|
1807
|
+
raise _AgentConfigError(
|
|
1808
|
+
f"The agent coding model {coding_model_id!r} does not "
|
|
1809
|
+
f"exist on {coding_base_url}.{have} Set 'Agent coding "
|
|
1810
|
+
"model name' in Settings → Agent to one of these exact "
|
|
1811
|
+
"names (for Ollama use the tag, e.g. qwen3:14b)."
|
|
1812
|
+
) from e
|
|
1813
|
+
last_err = e
|
|
1814
|
+
if attempt < 2:
|
|
1815
|
+
_log(f"generate transient error (attempt {attempt + 1}/3): {e} — retrying")
|
|
1816
|
+
time.sleep(1.5 * (attempt + 1))
|
|
1817
|
+
else:
|
|
1818
|
+
raise last_err
|
|
1819
|
+
finally:
|
|
1820
|
+
hb_stop.set()
|
|
1765
1821
|
try:
|
|
1766
1822
|
content = getattr(msg, "content", None)
|
|
1767
1823
|
if content is None:
|
|
@@ -1896,6 +1952,12 @@ def run_agent_chat(cfg):
|
|
|
1896
1952
|
model_id=coding_model_id,
|
|
1897
1953
|
api_base=coding_base_url,
|
|
1898
1954
|
api_key=agent_api_key,
|
|
1955
|
+
# Cap each HTTP attempt at 240s so one hung request (remote server
|
|
1956
|
+
# cold-loading a big model) fails into OUR retry loop instead of eating
|
|
1957
|
+
# the whole worker budget; by the retry the model is usually loaded.
|
|
1958
|
+
# max_retries=0: the openai client's own silent retries would otherwise
|
|
1959
|
+
# stack multiplicatively with ours (3 × 3 attempts).
|
|
1960
|
+
client_kwargs={"timeout": 240.0, "max_retries": 0},
|
|
1899
1961
|
)
|
|
1900
1962
|
agent_tools = [http_request, web_search, fetch_url, calculate,
|
|
1901
1963
|
get_current_datetime, create_pdf]
|
|
@@ -1929,6 +1991,23 @@ def run_agent_chat(cfg):
|
|
|
1929
1991
|
_log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
|
|
1930
1992
|
_emit({"type": "final", "text": final_text})
|
|
1931
1993
|
except Exception as e: # noqa: BLE001
|
|
1994
|
+
# Config errors (wrong coding-model name/URL) must reach the user VERBATIM —
|
|
1995
|
+
# they contain the fix (the server's real model list). smolagents wraps model
|
|
1996
|
+
# exceptions in AgentGenerationError, so walk the cause chain to find ours.
|
|
1997
|
+
cfg_err = None
|
|
1998
|
+
cur, depth = e, 0
|
|
1999
|
+
while cur is not None and depth < 6:
|
|
2000
|
+
if isinstance(cur, _AgentConfigError):
|
|
2001
|
+
cfg_err = cur
|
|
2002
|
+
break
|
|
2003
|
+
cur, depth = (getattr(cur, "__cause__", None)
|
|
2004
|
+
or getattr(cur, "__context__", None)), depth + 1
|
|
2005
|
+
if cfg_err is None and "Settings → Agent" in str(e):
|
|
2006
|
+
cfg_err = e # wrapped without a cause chain; message survived
|
|
2007
|
+
if cfg_err is not None:
|
|
2008
|
+
_log(f"agent config error (not degrading): {cfg_err}")
|
|
2009
|
+
_emit({"type": "final", "text": f"⚠️ {cfg_err}"})
|
|
2010
|
+
return
|
|
1932
2011
|
# An unexpected failure inside the agent loop should still return a useful
|
|
1933
2012
|
# answer from the conversation rather than surfacing a raw error to the user.
|
|
1934
2013
|
_log(f"agent error: {e} — degrading to plain reply")
|
|
@@ -1945,10 +2024,18 @@ def _log(text: str):
|
|
|
1945
2024
|
_emit({"type": "log", "text": text})
|
|
1946
2025
|
|
|
1947
2026
|
|
|
2027
|
+
_EMIT_LOCK = threading.Lock()
|
|
2028
|
+
|
|
2029
|
+
|
|
1948
2030
|
def _emit(obj):
|
|
1949
|
-
"""Write one NDJSON line to the real stdout and flush immediately.
|
|
1950
|
-
|
|
1951
|
-
|
|
2031
|
+
"""Write one NDJSON line to the real stdout and flush immediately.
|
|
2032
|
+
|
|
2033
|
+
Lock-guarded: the model-call heartbeat thread emits concurrently with the
|
|
2034
|
+
main loop, and interleaved writes would corrupt the NDJSON stream.
|
|
2035
|
+
"""
|
|
2036
|
+
with _EMIT_LOCK:
|
|
2037
|
+
_REAL_STDOUT.write(json.dumps(obj) + "\n")
|
|
2038
|
+
_REAL_STDOUT.flush()
|
|
1952
2039
|
|
|
1953
2040
|
|
|
1954
2041
|
def main():
|
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.138",
|
|
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",
|