@tiens.nguyen/gonext-local-worker 1.0.132 → 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
@@ -66,6 +67,22 @@ def _http_request_impl(method, url, headers=None, body=None, timeout=25):
66
67
  return f"Error: {e}"
67
68
 
68
69
 
70
+ def _normalize_openai_base(url: str) -> str:
71
+ """Normalize a model-server URL to its OpenAI-compatible /v1 root.
72
+
73
+ Users paste whatever their server docs show: an MLX root (http://host:8089), an
74
+ Ollama native endpoint (http://host:11434/api/generate), or a full /v1 URL. Ollama
75
+ serves the OpenAI-compatible API at /v1 on the same host, so strip any native
76
+ /api/... path (including a mistakenly appended /v1 after it) and ensure /v1.
77
+ """
78
+ u = (url or "").strip().rstrip("/")
79
+ if not u:
80
+ return ""
81
+ u = re.sub(r"/api(/(generate|chat|tags|embeddings|embed))?(/v1)?$", "", u,
82
+ flags=re.IGNORECASE)
83
+ return u if u.lower().endswith("/v1") else u + "/v1"
84
+
85
+
69
86
  def _html_to_text(html_text, limit=3000):
70
87
  """Strip HTML to readable plain text (zero-dep). Used by fetch_url so the weak
71
88
  model receives prose instead of raw tags it cannot parse."""
@@ -372,9 +389,16 @@ def _detect_model_id(base_url, api_key=""):
372
389
  return ""
373
390
  data = payload.get("data") if isinstance(payload, dict) else None
374
391
  if isinstance(data, list) and data:
375
- first = data[0]
376
- if isinstance(first, dict) and isinstance(first.get("id"), str):
377
- 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]
378
402
  return ""
379
403
 
380
404
 
@@ -934,7 +958,10 @@ def run_agent_chat(cfg):
934
958
  # Optional dedicated coding/reasoning model for the CodeAgent's tool-use loop.
935
959
  # Routing, plain replies and summarization stay on the chat model (better at
936
960
  # natural language); the code model only drives http_request reasoning.
937
- raw_coding_base = (cfg.get("codingBaseURL") or "").strip()
961
+ # The URL may be a pasted Ollama endpoint (http://host:11434/api/generate) or a
962
+ # bare host — normalize to the OpenAI-compatible /v1 root either way (defensive:
963
+ # also fixes an older API layer that appended /v1 after the native /api path).
964
+ raw_coding_base = _normalize_openai_base(cfg.get("codingBaseURL") or "")
938
965
  raw_coding_model = (cfg.get("codingModelId") or "").strip()
939
966
  if raw_coding_base:
940
967
  same_server = raw_coding_base.rstrip("/") == (agent_base_url or "").rstrip("/")
@@ -1731,18 +1758,38 @@ def run_agent_chat(cfg):
1731
1758
  # A transient "Connection error" to the local MLX server (seen mid-loop when the
1732
1759
  # context grows large) otherwise aborts the whole run — retry a couple of times
1733
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()
1734
1778
  last_err = None
1735
- for attempt in range(3):
1736
- try:
1737
- msg = super().generate(*args, **kwargs)
1738
- break
1739
- except Exception as e: # noqa: BLE001
1740
- last_err = e
1741
- if attempt < 2:
1742
- _log(f"generate transient error (attempt {attempt + 1}/3): {e} — retrying")
1743
- time.sleep(1.5 * (attempt + 1))
1744
- else:
1745
- 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()
1746
1793
  try:
1747
1794
  content = getattr(msg, "content", None)
1748
1795
  if content is None:
@@ -1877,6 +1924,12 @@ def run_agent_chat(cfg):
1877
1924
  model_id=coding_model_id,
1878
1925
  api_base=coding_base_url,
1879
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},
1880
1933
  )
1881
1934
  agent_tools = [http_request, web_search, fetch_url, calculate,
1882
1935
  get_current_datetime, create_pdf]
@@ -1926,10 +1979,18 @@ def _log(text: str):
1926
1979
  _emit({"type": "log", "text": text})
1927
1980
 
1928
1981
 
1982
+ _EMIT_LOCK = threading.Lock()
1983
+
1984
+
1929
1985
  def _emit(obj):
1930
- """Write one NDJSON line to the real stdout and flush immediately."""
1931
- _REAL_STDOUT.write(json.dumps(obj) + "\n")
1932
- _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()
1933
1994
 
1934
1995
 
1935
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.132",
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",