@tiens.nguyen/gonext-local-worker 1.0.134 → 1.0.136

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
@@ -388,9 +389,16 @@ def _detect_model_id(base_url, api_key=""):
388
389
  return ""
389
390
  data = payload.get("data") if isinstance(payload, dict) else None
390
391
  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()
392
+ ids = [d["id"].strip() for d in data
393
+ if isinstance(d, dict) and isinstance(d.get("id"), str) and d["id"].strip()]
394
+ if ids:
395
+ if len(ids) > 1:
396
+ # Ollama servers list every pulled model; "first" is arbitrary and may
397
+ # be a huge/slow one (seen live: picked gemma4:31b over qwen3:14b).
398
+ _log(f"model detect: server hosts {len(ids)} models {ids} — using "
399
+ f"{ids[0]!r}. Set the coding model NAME in Settings to choose "
400
+ "a specific one.")
401
+ return ids[0]
394
402
  return ""
395
403
 
396
404
 
@@ -1750,18 +1758,38 @@ def run_agent_chat(cfg):
1750
1758
  # A transient "Connection error" to the local MLX server (seen mid-loop when the
1751
1759
  # context grows large) otherwise aborts the whole run — retry a couple of times
1752
1760
  # with a short backoff before giving up.
1761
+ # Heartbeat: while a model call is in flight the agent emits NOTHING, and
1762
+ # the web's no-progress watchdog kills the chat. A big/cold model (e.g. a
1763
+ # 31B on a remote Ollama box) can take minutes on prompt-eval before the
1764
+ # first byte — emit a keepalive step every 45s until the call returns.
1765
+ hb_stop = threading.Event()
1766
+
1767
+ def _heartbeat():
1768
+ waited = 0
1769
+ while not hb_stop.wait(45):
1770
+ waited += 45
1771
+ _emit({"type": "step",
1772
+ "text": f"Model is still working… ({waited}s — a large or "
1773
+ "cold-loading model can take a few minutes)"})
1774
+ _log(f"heartbeat: model call in flight {waited}s")
1775
+
1776
+ hb = threading.Thread(target=_heartbeat, daemon=True)
1777
+ hb.start()
1753
1778
  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
1779
+ try:
1780
+ for attempt in range(3):
1781
+ try:
1782
+ msg = super().generate(*args, **kwargs)
1783
+ break
1784
+ except Exception as e: # noqa: BLE001
1785
+ last_err = e
1786
+ if attempt < 2:
1787
+ _log(f"generate transient error (attempt {attempt + 1}/3): {e} — retrying")
1788
+ time.sleep(1.5 * (attempt + 1))
1789
+ else:
1790
+ raise last_err
1791
+ finally:
1792
+ hb_stop.set()
1765
1793
  try:
1766
1794
  content = getattr(msg, "content", None)
1767
1795
  if content is None:
@@ -1896,6 +1924,12 @@ def run_agent_chat(cfg):
1896
1924
  model_id=coding_model_id,
1897
1925
  api_base=coding_base_url,
1898
1926
  api_key=agent_api_key,
1927
+ # Cap each HTTP attempt at 240s so one hung request (remote server
1928
+ # cold-loading a big model) fails into OUR retry loop instead of eating
1929
+ # the whole worker budget; by the retry the model is usually loaded.
1930
+ # max_retries=0: the openai client's own silent retries would otherwise
1931
+ # stack multiplicatively with ours (3 × 3 attempts).
1932
+ client_kwargs={"timeout": 240.0, "max_retries": 0},
1899
1933
  )
1900
1934
  agent_tools = [http_request, web_search, fetch_url, calculate,
1901
1935
  get_current_datetime, create_pdf]
@@ -1945,10 +1979,18 @@ def _log(text: str):
1945
1979
  _emit({"type": "log", "text": text})
1946
1980
 
1947
1981
 
1982
+ _EMIT_LOCK = threading.Lock()
1983
+
1984
+
1948
1985
  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()
1986
+ """Write one NDJSON line to the real stdout and flush immediately.
1987
+
1988
+ Lock-guarded: the model-call heartbeat thread emits concurrently with the
1989
+ main loop, and interleaved writes would corrupt the NDJSON stream.
1990
+ """
1991
+ with _EMIT_LOCK:
1992
+ _REAL_STDOUT.write(json.dumps(obj) + "\n")
1993
+ _REAL_STDOUT.flush()
1952
1994
 
1953
1995
 
1954
1996
  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.136",
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",