@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.
@@ -1526,12 +1526,14 @@ async function runAgentChatJob(job) {
1526
1526
  emailFrom: payload?.emailFrom ?? "",
1527
1527
  emailAllowList: payload?.emailAllowList ?? "",
1528
1528
  });
1529
- // 7 min max for an agent run: up to 5 ReAct steps on a local 14B with a cold
1530
- // prompt cache can legitimately exceed the old 5-min cap. Must stay BELOW the web
1531
- // client's hard WS deadline (480s) so our clean timeout error reaches the user
1532
- // instead of a generic WS timeout. On expiry the python child is SIGKILLed, which
1533
- // also shows up as a BrokenPipeError in the mlx_lm.server log (expected).
1534
- const timeoutMs = 420_000;
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 = "";
@@ -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
- def _detect_model_id(base_url, api_key=""):
372
- """Ask an OpenAI-compatible server which model it serves.
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
- Queries GET {base_url}/models and returns the first reported model id.
375
- `base_url` already ends with /v1. Returns "" on any failure so callers can
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 detect failed {url}: {e}")
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) and data:
391
- first = data[0]
392
- if isinstance(first, dict) and isinstance(first.get("id"), str):
393
- return first["id"].strip()
394
- return ""
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
- for attempt in range(3):
1755
- try:
1756
- msg = super().generate(*args, **kwargs)
1757
- break
1758
- except Exception as e: # noqa: BLE001
1759
- last_err = e
1760
- if attempt < 2:
1761
- _log(f"generate transient error (attempt {attempt + 1}/3): {e} retrying")
1762
- time.sleep(1.5 * (attempt + 1))
1763
- else:
1764
- raise last_err
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
- _REAL_STDOUT.write(json.dumps(obj) + "\n")
1951
- _REAL_STDOUT.flush()
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.134",
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",