@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.
- package/gonext-local-worker.mjs +8 -6
- package/gonext_agent_chat.py +79 -18
- 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
|
|
@@ -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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
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
|
-
|
|
1932
|
-
|
|
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.
|
|
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",
|