@tiens.nguyen/gonext-local-worker 1.0.328 → 1.0.332
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 +104 -15
- package/gonext_agent_chat.py +323 -46
- package/package.json +2 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1768,6 +1768,76 @@ function _warmupPing(quiet) {
|
|
|
1768
1768
|
});
|
|
1769
1769
|
}
|
|
1770
1770
|
|
|
1771
|
+
// ─── Coding-backend resolution (task #117, folded into #123 Part B) ──────────────────
|
|
1772
|
+
// ONE answer to "which coding backend is this job using?", for the two decisions THIS
|
|
1773
|
+
// process makes before python starts: keep an Ollama model warm, and fetch the
|
|
1774
|
+
// OpenAI-compatible API key. The answer is passed down as `codingBackend` so python
|
|
1775
|
+
// doesn't re-derive it and can't disagree.
|
|
1776
|
+
//
|
|
1777
|
+
// The reference implementation is _resolve_coding_backend() in gonext_agent_chat.py —
|
|
1778
|
+
// it is pure and carries the offline resolution table (tests/test_backend_resolution.py).
|
|
1779
|
+
// Keep this rule in step with it: explicit kind wins → Ollama probe → remote ⇒ openai →
|
|
1780
|
+
// local. The probe runs BEFORE the remote rule so a remote Ollama box stays "ollama".
|
|
1781
|
+
|
|
1782
|
+
// root → { value, until }. A YES is cached long (a box doesn't stop being Ollama), a NO
|
|
1783
|
+
// only briefly: "not Ollama" and "the probe failed" are indistinguishable here, so caching
|
|
1784
|
+
// a failure would strand a box that was merely booting/unreachable for one job.
|
|
1785
|
+
const _ollamaProbeCache = new Map();
|
|
1786
|
+
const _PROBE_TTL_YES_MS = 30 * 60_000;
|
|
1787
|
+
const _PROBE_TTL_NO_MS = 60_000;
|
|
1788
|
+
|
|
1789
|
+
async function _probeIsOllama(baseURL) {
|
|
1790
|
+
const root = String(baseURL || "").replace(/\/+$/, "").replace(/\/v1$/i, "");
|
|
1791
|
+
if (!root) return false;
|
|
1792
|
+
const hit = _ollamaProbeCache.get(root);
|
|
1793
|
+
if (hit && hit.until > Date.now()) return hit.value;
|
|
1794
|
+
let isOllama = false;
|
|
1795
|
+
try {
|
|
1796
|
+
// Ollama answers GET / with the plain-text banner "Ollama is running".
|
|
1797
|
+
const res = await fetch(root, {
|
|
1798
|
+
headers: { "User-Agent": "gonext-worker/1.0" },
|
|
1799
|
+
signal: AbortSignal.timeout(6000),
|
|
1800
|
+
});
|
|
1801
|
+
isOllama = res.ok && /ollama/i.test((await res.text()).slice(0, 200));
|
|
1802
|
+
} catch {
|
|
1803
|
+
isOllama = false; // unreachable/TLS/404 → not provably Ollama
|
|
1804
|
+
}
|
|
1805
|
+
_ollamaProbeCache.set(root, {
|
|
1806
|
+
value: isOllama,
|
|
1807
|
+
until: Date.now() + (isOllama ? _PROBE_TTL_YES_MS : _PROBE_TTL_NO_MS),
|
|
1808
|
+
});
|
|
1809
|
+
return isOllama;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function _isLocalCodingUrl(baseURL) {
|
|
1813
|
+
let host = "";
|
|
1814
|
+
try {
|
|
1815
|
+
host = new URL(String(baseURL || "")).hostname.toLowerCase().replace(/\.$/, "");
|
|
1816
|
+
} catch {
|
|
1817
|
+
return true; // unparseable → we are certainly not talking to a paid remote
|
|
1818
|
+
}
|
|
1819
|
+
if (!host) return true;
|
|
1820
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
1821
|
+
if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
|
|
1822
|
+
if (/^127(\.\d{1,3}){3}$/.test(host)) return true;
|
|
1823
|
+
if (/^(10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})$/.test(host))
|
|
1824
|
+
return true;
|
|
1825
|
+
if (host.includes(":") && (host.startsWith("fe80:") || /^f[cd]/.test(host))) return true;
|
|
1826
|
+
if (/\.(local|localdomain|lan|home|internal)$/.test(host)) return true;
|
|
1827
|
+
// A dotless hostname ("mac-studio", "ollama1") is a LAN box: a public host always has a
|
|
1828
|
+
// dot, and erring this way only ever means "don't expect an API key here".
|
|
1829
|
+
return !host.includes(".") && !host.includes(":");
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** Resolve to "ollama" | "openai" | "local". `kind` is the user's Settings choice
|
|
1833
|
+
* ("ollama" | "openai" | "" = Auto). Only Auto costs a probe. */
|
|
1834
|
+
async function resolveCodingBackend(kind, baseURL) {
|
|
1835
|
+
if (kind === "ollama") return "ollama";
|
|
1836
|
+
if (kind === "openai") return "openai";
|
|
1837
|
+
if (await _probeIsOllama(baseURL)) return "ollama";
|
|
1838
|
+
return _isLocalCodingUrl(baseURL) ? "local" : "openai";
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1771
1841
|
/**
|
|
1772
1842
|
* Remember the agent's Ollama coding model (from a claimed agent_chat job) and keep it
|
|
1773
1843
|
* resident via a background interval. Warms IMMEDIATELY only when the model is first
|
|
@@ -1801,19 +1871,33 @@ async function runAgentChatJob(job) {
|
|
|
1801
1871
|
// "cancelled" status with completed/failed.
|
|
1802
1872
|
const cancelAc = new AbortController();
|
|
1803
1873
|
let cancelled = false;
|
|
1804
|
-
//
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
// Ollama's native /api/generate, so SKIP it for an OpenAI-compatible coder (task #108):
|
|
1808
|
-
// that endpoint doesn't exist there and we shouldn't hit a paid API with keep-warm pings.
|
|
1809
|
-
if (payload?.codingKind !== "openai") {
|
|
1810
|
-
trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
|
|
1811
|
-
}
|
|
1874
|
+
// Task #108: the user's Coding backend setting ("ollama" | "openai" | "" = Auto).
|
|
1875
|
+
const codingKind =
|
|
1876
|
+
payload?.codingKind === "ollama" || payload?.codingKind === "openai" ? payload.codingKind : "";
|
|
1812
1877
|
const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
1813
1878
|
method: "PATCH",
|
|
1814
1879
|
body: JSON.stringify({ jobStatus: "running" }),
|
|
1815
1880
|
});
|
|
1816
1881
|
await ensureWorkerOk(runRes, `mark running agent_chat jobId=${jobId}`);
|
|
1882
|
+
// Task #117/#123: `codingKind` above is the SETTING, not the answer — resolve the actual
|
|
1883
|
+
// backend ONCE here and let both decisions below, plus python, read that one value.
|
|
1884
|
+
// AFTER the "running" PATCH on purpose: an Auto config costs a probe (~6s worst case,
|
|
1885
|
+
// then cached), and the REPL shouldn't watch a claimed job sit in "queued" for it.
|
|
1886
|
+
const codingBackend = await resolveCodingBackend(codingKind, payload?.codingBaseURL ?? "");
|
|
1887
|
+
console.log(
|
|
1888
|
+
`[gonext-worker] agent job ${jobId}: coding backend ${codingBackend}` +
|
|
1889
|
+
` (kind=${codingKind || "auto"}, ${payload?.codingBaseURL || "no coding url"})`
|
|
1890
|
+
);
|
|
1891
|
+
// Remember this job's Ollama coding model and keep it resident via a background
|
|
1892
|
+
// interval. Only warms now if the model is new/changed — repeat questions rely on
|
|
1893
|
+
// the interval, so we don't re-warm (or re-log) on every question. The warm-up uses
|
|
1894
|
+
// Ollama's native /api/generate, so it runs ONLY for an actual Ollama backend: on
|
|
1895
|
+
// anything else that endpoint doesn't exist, and on a cloud one we'd be pinging a paid
|
|
1896
|
+
// API every 4 minutes (which an Auto+cloud config did until #117 was fixed — the old
|
|
1897
|
+
// gate only skipped an EXPLICIT openai kind).
|
|
1898
|
+
if (codingBackend === "ollama") {
|
|
1899
|
+
trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
|
|
1900
|
+
}
|
|
1817
1901
|
|
|
1818
1902
|
let buf = "";
|
|
1819
1903
|
let flushTimer = null;
|
|
@@ -1902,14 +1986,14 @@ async function runAgentChatJob(job) {
|
|
|
1902
1986
|
jobActiveWorkspace = webWork;
|
|
1903
1987
|
}
|
|
1904
1988
|
|
|
1905
|
-
// Task #108:
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1989
|
+
// Task #108: for an OpenAI-compatible coder, fetch the API key from a dedicated
|
|
1990
|
+
// worker-authed endpoint (NOT the job payload/Mongo doc — secret hygiene) and inject
|
|
1991
|
+
// it into python stdin only. Keyed off the RESOLVED backend (#117), so an Auto config
|
|
1992
|
+
// pointing at a cloud endpoint now gets its key: it used to require the explicit
|
|
1993
|
+
// "openai" kind, and python's `coding_api_key or agent_api_key` fallback then
|
|
1994
|
+
// authenticated a cloud provider with the LOCAL agent key → 401.
|
|
1911
1995
|
let codingApiKey = "";
|
|
1912
|
-
if (
|
|
1996
|
+
if (codingBackend === "openai") {
|
|
1913
1997
|
try {
|
|
1914
1998
|
const kr = await workerFetch("/api/worker/coding-key");
|
|
1915
1999
|
if (kr.ok) {
|
|
@@ -1929,6 +2013,11 @@ async function runAgentChatJob(job) {
|
|
|
1929
2013
|
codingBaseURL: payload?.codingBaseURL ?? "",
|
|
1930
2014
|
codingModelId: payload?.codingModelId ?? "",
|
|
1931
2015
|
codingKind,
|
|
2016
|
+
// The resolved backend (#117): python trusts this instead of probing again, so the
|
|
2017
|
+
// key fetch above and python's streaming / repair / step-budget branches can't
|
|
2018
|
+
// disagree about what this endpoint is. Python re-resolves only if it ends up on a
|
|
2019
|
+
// DIFFERENT url than the one judged here (its no-model-id fallback to the chat model).
|
|
2020
|
+
codingBackend,
|
|
1932
2021
|
codingApiKey,
|
|
1933
2022
|
// Task #107: when true, python emits a {type:code_response} event per code-model
|
|
1934
2023
|
// call so the worker persists the full raw text to Mongo.
|
package/gonext_agent_chat.py
CHANGED
|
@@ -879,6 +879,112 @@ def _is_ollama_server(base_url):
|
|
|
879
879
|
return False
|
|
880
880
|
|
|
881
881
|
|
|
882
|
+
# Loopback and RFC1918/link-local ranges — a URL on one of these is a box the user owns,
|
|
883
|
+
# which is the whole point: only a REMOTE endpoint can plausibly need an API key.
|
|
884
|
+
_LOOPBACK_RE = re.compile(r"^127(?:\.\d{1,3}){3}$")
|
|
885
|
+
_PRIVATE_V4_RE = re.compile(
|
|
886
|
+
r"^(?:10(?:\.\d{1,3}){3}"
|
|
887
|
+
r"|192\.168(?:\.\d{1,3}){2}"
|
|
888
|
+
r"|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}"
|
|
889
|
+
r"|169\.254(?:\.\d{1,3}){2})$"
|
|
890
|
+
)
|
|
891
|
+
# mDNS / router-assigned search domains. A bare dotless hostname counts too (see below).
|
|
892
|
+
_LOCAL_SUFFIXES = (".local", ".localdomain", ".lan", ".home", ".internal")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _is_local_url(base_url) -> bool:
|
|
896
|
+
"""True when the base URL points at this machine or the local network.
|
|
897
|
+
|
|
898
|
+
Kept pure (no I/O) so the backend resolution table can be replayed offline. A dotless
|
|
899
|
+
hostname ("mac-studio", "ollama1") is treated as LAN: a public host effectively always
|
|
900
|
+
has a dot, and being wrong in that direction is the safe way — a LAN box is simply
|
|
901
|
+
never told to expect an API key.
|
|
902
|
+
"""
|
|
903
|
+
host = _host_of(base_url)
|
|
904
|
+
if "@" in host:
|
|
905
|
+
host = host.rsplit("@", 1)[1] # strip user:pass@
|
|
906
|
+
if host.startswith("["):
|
|
907
|
+
host = host[1:].split("]", 1)[0] # [::1]:8080 → ::1
|
|
908
|
+
elif host.count(":") == 1:
|
|
909
|
+
host = host.rsplit(":", 1)[0] # host:port → host
|
|
910
|
+
host = host.strip().lower().rstrip(".")
|
|
911
|
+
if not host:
|
|
912
|
+
return True # no host at all → we are not talking to anyone remote
|
|
913
|
+
if host in ("localhost", "0.0.0.0", "::", "::1"):
|
|
914
|
+
return True
|
|
915
|
+
if _LOOPBACK_RE.match(host) or _PRIVATE_V4_RE.match(host):
|
|
916
|
+
return True
|
|
917
|
+
if ":" in host and (host.startswith("fe80:") or host[:2] in ("fc", "fd")):
|
|
918
|
+
return True # IPv6 link-local / unique-local
|
|
919
|
+
if host.endswith(_LOCAL_SUFFIXES):
|
|
920
|
+
return True
|
|
921
|
+
return "." not in host and ":" not in host
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _resolve_coding_backend(kind, base_url, probe=None):
|
|
925
|
+
"""Answer "which coding backend is this?" ONCE. Returns (backend, why) where backend
|
|
926
|
+
is one of "ollama" | "openai" | "local" and `why` is log copy explaining the choice.
|
|
927
|
+
|
|
928
|
+
Task #117 (folded into #123). This used to be re-derived in three places from TWO
|
|
929
|
+
different questions — `coding_is_ollama` ("is this an Ollama server?", explicit kind
|
|
930
|
+
else a URL sniff) and `coding_kind == "openai"` ("did the user explicitly pick
|
|
931
|
+
OpenAI-compatible?") — and Auto fell between them: a cloud endpoint left on the
|
|
932
|
+
default Auto sniffed as not-Ollama and was not explicitly openai, so it got NEITHER
|
|
933
|
+
treatment (no streaming, none of the #113 reasoning repairs, and the small local step
|
|
934
|
+
budget). Every branch now keys off this one value.
|
|
935
|
+
|
|
936
|
+
Order matters. The Ollama probe runs BEFORE the remote-host rule so a remote Ollama
|
|
937
|
+
(ollama1.gomarsic.cc) still resolves to "ollama" and keeps its own treatment.
|
|
938
|
+
|
|
939
|
+
explicit kind → that value
|
|
940
|
+
probe says Ollama → "ollama"
|
|
941
|
+
remote URL (not ours) → "openai" ← only a remote endpoint needs a key
|
|
942
|
+
otherwise → "local" (an MLX box on 127.0.0.1)
|
|
943
|
+
|
|
944
|
+
Deliberately NOT keyed off "is an API key set": the Settings UI only renders the key
|
|
945
|
+
field once the OpenAI-compatible kind is picked, so on Auto the user cannot supply one
|
|
946
|
+
— a stored key there is only ever a leftover from switching the dropdown back, and
|
|
947
|
+
using it as the test would make the answer depend on that history.
|
|
948
|
+
|
|
949
|
+
`probe` is injectable so the resolution table can be replayed offline without network.
|
|
950
|
+
"""
|
|
951
|
+
k = (kind or "").strip().lower()
|
|
952
|
+
if k == "ollama":
|
|
953
|
+
return "ollama", "explicit setting"
|
|
954
|
+
if k == "openai":
|
|
955
|
+
return "openai", "explicit setting"
|
|
956
|
+
if (probe or _is_ollama_server)(base_url):
|
|
957
|
+
return "ollama", "auto: server answers as Ollama"
|
|
958
|
+
if not _is_local_url(base_url):
|
|
959
|
+
return "openai", "auto: remote URL, not Ollama"
|
|
960
|
+
return "local", "auto: local URL, not Ollama"
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _coding_backend_flags(backend):
|
|
964
|
+
"""Everything the resolved backend decides, derived in ONE place (task #117 → #123).
|
|
965
|
+
|
|
966
|
+
Pure, so the whole resolution → behaviour table can be replayed offline. The callers
|
|
967
|
+
below must not re-derive any of this from `coding_kind` or a URL sniff — that drift is
|
|
968
|
+
the bug this fixes.
|
|
969
|
+
|
|
970
|
+
ollama_tweaks — reasoning_effort='none' (Ollama's OpenAI-compat endpoint ignores
|
|
971
|
+
Qwen3's /no_think but honors this) and the worker's keep-warm ping.
|
|
972
|
+
stream — Ollama and remote OpenAI-compatible endpoints stream, so a long
|
|
973
|
+
generation can't trip a proxy idle read-timeout and include_usage
|
|
974
|
+
keeps output-token counting alive. Local MLX stays non-streaming.
|
|
975
|
+
openai_repairs— the #113 reasoning-model repairs (prose-only nudge, parse-error
|
|
976
|
+
sanitizing, no-code streak breaker). Never applied to Ollama/MLX.
|
|
977
|
+
budget — (default, workspace) step budget: a cloud coder is stronger and
|
|
978
|
+
cheaper-per-step than a small local model, so it gets more room.
|
|
979
|
+
"""
|
|
980
|
+
return {
|
|
981
|
+
"ollama_tweaks": backend == "ollama",
|
|
982
|
+
"stream": backend in ("ollama", "openai"),
|
|
983
|
+
"openai_repairs": backend == "openai",
|
|
984
|
+
"budget": (20, 40) if backend == "openai" else (10, 20) if backend == "ollama" else (5, 12),
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
|
|
882
988
|
def _detect_model_id(base_url, api_key=""):
|
|
883
989
|
"""Ask an OpenAI-compatible server which model it serves (first reported id).
|
|
884
990
|
|
|
@@ -1528,6 +1634,49 @@ def _is_backend_overloaded(err) -> bool:
|
|
|
1528
1634
|
)
|
|
1529
1635
|
|
|
1530
1636
|
|
|
1637
|
+
def _is_auth_failure(err) -> bool:
|
|
1638
|
+
"""True when the model server REJECTED OUR CREDENTIAL — a deterministic config error
|
|
1639
|
+
that must fail fast rather than retry (task #117 companion).
|
|
1640
|
+
|
|
1641
|
+
Deliberately narrow:
|
|
1642
|
+
• 401 is always an auth failure.
|
|
1643
|
+
• 403 is NOT, on its own. Several providers return 403 for QUOTA EXHAUSTED as well as
|
|
1644
|
+
for permission-denied, and reporting "you're out of credit" as "your API key is
|
|
1645
|
+
wrong" sends the user to fix the wrong thing. A 403 counts only when the body does
|
|
1646
|
+
NOT read like a quota/rate problem — those belong to _is_backend_overloaded.
|
|
1647
|
+
• 402 (payment required) is a billing problem, not a credential one — excluded here
|
|
1648
|
+
so it can carry its own message.
|
|
1649
|
+
Falls back to message text only when the exception carries no HTTP status (a bare
|
|
1650
|
+
string, or a wrapper that lost its cause)."""
|
|
1651
|
+
if _is_backend_overloaded(err):
|
|
1652
|
+
return False # a 429 (or a quota-flavoured 403) is never an auth failure
|
|
1653
|
+
s = str(err or "").lower()
|
|
1654
|
+
code = _http_status_of(err)
|
|
1655
|
+
if code is not None:
|
|
1656
|
+
if code == 401:
|
|
1657
|
+
return True
|
|
1658
|
+
if code == 403:
|
|
1659
|
+
return not re.search(r"quota|rate.?limit|billing|credit|capacity", s)
|
|
1660
|
+
return False
|
|
1661
|
+
return (
|
|
1662
|
+
"401" in s
|
|
1663
|
+
or "invalid_api_key" in s
|
|
1664
|
+
or "invalid api key" in s
|
|
1665
|
+
or "incorrect api key" in s
|
|
1666
|
+
or "unauthorized" in s
|
|
1667
|
+
or "authentication" in s and "fail" in s
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def _is_billing_failure(err) -> bool:
|
|
1672
|
+
"""True for 402 / explicit out-of-credit responses — deterministic like an auth failure
|
|
1673
|
+
but with a different fix, so it gets its own message instead of 'check your API key'."""
|
|
1674
|
+
if _http_status_of(err) == 402:
|
|
1675
|
+
return True
|
|
1676
|
+
s = str(err or "").lower()
|
|
1677
|
+
return "insufficient_quota" in s or "exceeded your current quota" in s
|
|
1678
|
+
|
|
1679
|
+
|
|
1531
1680
|
def _retry_after_seconds(err):
|
|
1532
1681
|
"""The provider's Retry-After hint in seconds, or None. Reads the header off an
|
|
1533
1682
|
openai-python APIStatusError (`err.response.headers`) and falls back to a
|
|
@@ -1862,12 +2011,68 @@ def _looks_like_tool_code(code: str):
|
|
|
1862
2011
|
return True, ""
|
|
1863
2012
|
|
|
1864
2013
|
|
|
2014
|
+
_CODE_PAIR_RE = r"<code[^>]*>[\s\S]*?</code>"
|
|
2015
|
+
|
|
2016
|
+
|
|
2017
|
+
def _close_dangling_code_block(text):
|
|
2018
|
+
"""Close a code block the model OPENED but never CLOSED, when what follows it is a
|
|
2019
|
+
runnable tool call — the normal shape whenever the server honors `stop` (task #119).
|
|
2020
|
+
|
|
2021
|
+
smolagents appends its closing tag to the stop_sequences of every CodeAgent turn
|
|
2022
|
+
(agents.py: `stop_sequences.append(self.code_block_tags[1])`), so any backend that
|
|
2023
|
+
honors `stop` — i.e. every OpenAI-compatible API — returns the block WITHOUT its
|
|
2024
|
+
closer. Confirmed against the raw responses saved from a live run: what the model
|
|
2025
|
+
actually sends is `<code>\\nrun_command("npm run build", …)` and nothing after it.
|
|
2026
|
+
smolagents re-appends the tag itself once it has the text (agents.py, "This adds the
|
|
2027
|
+
end code sequence … to the history"), which is why a trace shows a tidy `…</code>`
|
|
2028
|
+
that never came off the wire — but every check WE run in between tested for the PAIR
|
|
2029
|
+
`<code>…</code>`, a delimiter this backend structurally cannot produce.
|
|
2030
|
+
|
|
2031
|
+
What that cost, live: a perfectly good turn was read as a "dangling code tag", burned
|
|
2032
|
+
a second model call on the CODE-NOW retry, and the retry's equally good opener-only
|
|
2033
|
+
reply was then DISCARDED for failing the same pair test — so every other step died on
|
|
2034
|
+
a parse error the model never caused, while the user watched "Error in code parsing"
|
|
2035
|
+
scroll past on steps that had in fact called the right tool.
|
|
2036
|
+
|
|
2037
|
+
So: canonicalize first, decide second. Everything after the LAST opener is taken as
|
|
2038
|
+
the block, a trailing "Thought:" line and stray tags are cut, and the result is
|
|
2039
|
+
substituted ONLY when _looks_like_tool_code passes (it compiles AND calls something)
|
|
2040
|
+
— the same #113 validity gate used for reasoning-channel recovery, so prose that
|
|
2041
|
+
merely trails a stray tag is never promoted into executable code.
|
|
2042
|
+
|
|
2043
|
+
No-op when the text already holds a pair, has no opener, or the tail isn't runnable;
|
|
2044
|
+
idempotent. Deliberately backend-agnostic: `stop` truncation is a property of the
|
|
2045
|
+
protocol, not of one vendor, and the gate makes it inert everywhere else."""
|
|
2046
|
+
if not isinstance(text, str) or not text:
|
|
2047
|
+
return text
|
|
2048
|
+
if re.search(_CODE_PAIR_RE, text, re.I):
|
|
2049
|
+
return text # a real block already — never touch it
|
|
2050
|
+
opens = list(re.finditer(r"<code[^>]*>", text, re.I))
|
|
2051
|
+
if not opens:
|
|
2052
|
+
return text # prose turn, or a lone stray closer (see #111)
|
|
2053
|
+
last = opens[-1]
|
|
2054
|
+
body = re.split(r"\n\s*Thought\s*:\s", text[last.end():], maxsplit=1)[0]
|
|
2055
|
+
body = re.sub(r"</?code[^>]*>", "", body, flags=re.I).strip()
|
|
2056
|
+
ok, _why = _looks_like_tool_code(body)
|
|
2057
|
+
if not ok:
|
|
2058
|
+
return text # not runnable — leave the step to fail honestly
|
|
2059
|
+
# Keep the Thought prose that preceded the block, minus any earlier stray tag (an
|
|
2060
|
+
# unbalanced opener left in the head would let the pair regex start from the wrong
|
|
2061
|
+
# place and swallow the prose into the code).
|
|
2062
|
+
head = re.sub(r"</?code[^>]*>", "", text[:last.start()], flags=re.I).rstrip()
|
|
2063
|
+
return (head + "\n" if head else "") + "<code>\n" + body + "\n</code>"
|
|
2064
|
+
|
|
2065
|
+
|
|
1865
2066
|
def _has_runnable_block(text) -> bool:
|
|
1866
2067
|
"""True if `text` contains a <code>…</code> block whose python actually compiles —
|
|
1867
2068
|
i.e. this step produced something that can run. Used by the #113 no-code loop-breaker
|
|
1868
|
-
(openai backend only) to tell "the step worked" from "the model wrote prose again".
|
|
2069
|
+
(openai backend only) to tell "the step worked" from "the model wrote prose again".
|
|
2070
|
+
|
|
2071
|
+
Closes a stop-truncated block first (#119), so a step that DID call a tool is never
|
|
2072
|
+
counted toward the no-code streak just because the API ate the closing tag."""
|
|
1869
2073
|
if not isinstance(text, str) or not text:
|
|
1870
2074
|
return False
|
|
2075
|
+
text = _close_dangling_code_block(text)
|
|
1871
2076
|
blk = re.search(r"<code[^>]*>([\s\S]*?)</code>", text, re.I)
|
|
1872
2077
|
if not blk:
|
|
1873
2078
|
return False
|
|
@@ -1884,7 +2089,10 @@ def _code_nudge_reason(content, openai_backend: bool):
|
|
|
1884
2089
|
replayed offline against real traces (task #113).
|
|
1885
2090
|
|
|
1886
2091
|
1. empty-content — the whole turn went to the reasoning channel, no message.
|
|
1887
|
-
2. dangling tag — a stray <code>/</code> with no valid pair.
|
|
2092
|
+
2. dangling tag — a stray <code>/</code> with no valid pair. A block the server
|
|
2093
|
+
merely TRUNCATED at the `</code>` stop sequence is not that (task #119): it is
|
|
2094
|
+
closed first and accepted as-is, so a step that already called a tool no longer
|
|
2095
|
+
pays for a second model call to be told to do what it just did.
|
|
1888
2096
|
3. prose-only plan — narration of what it is ABOUT to do, no tag at all. This one
|
|
1889
2097
|
is openai-only and additionally requires _looks_like_scratchpad, so a FINISHED
|
|
1890
2098
|
prose answer is never nudged into inventing another tool call. It exists because
|
|
@@ -1896,8 +2104,8 @@ def _code_nudge_reason(content, openai_backend: bool):
|
|
|
1896
2104
|
text = (content or "").strip()
|
|
1897
2105
|
if not text:
|
|
1898
2106
|
return "empty-content"
|
|
1899
|
-
if re.search(
|
|
1900
|
-
return None # a real block — nothing to nudge
|
|
2107
|
+
if re.search(_CODE_PAIR_RE, _close_dangling_code_block(content), re.I):
|
|
2108
|
+
return None # a real block (closing a stop-truncated one first) — nothing to nudge
|
|
1901
2109
|
if re.search(r"</?code[\s>/]", content, re.I):
|
|
1902
2110
|
return "dangling code tag, no valid block"
|
|
1903
2111
|
if openai_backend and _looks_like_scratchpad(text):
|
|
@@ -1922,9 +2130,12 @@ def _recover_code_from_reasoning(content, reasoning):
|
|
|
1922
2130
|
Candidates are tried NEWEST-first and the first runnable one wins; if none is
|
|
1923
2131
|
runnable we return `content` untouched and let the step fail normally, which
|
|
1924
2132
|
is what happened before #111 existed."""
|
|
1925
|
-
_pair =
|
|
1926
|
-
if re.search(_pair, content or "", re.I):
|
|
1927
|
-
|
|
2133
|
+
_pair = _CODE_PAIR_RE
|
|
2134
|
+
if re.search(_pair, _close_dangling_code_block(content) or "", re.I):
|
|
2135
|
+
# Content already has a usable block — never override it. A block the server
|
|
2136
|
+
# truncated at the `</code>` stop sequence counts as usable (#119): the visible
|
|
2137
|
+
# channel is the model's actual answer and must win over a reasoning-channel draft.
|
|
2138
|
+
return content
|
|
1928
2139
|
reasoning = reasoning or ""
|
|
1929
2140
|
if not reasoning.strip():
|
|
1930
2141
|
return content # nothing to recover from (Ollama / non-reasoning model)
|
|
@@ -3404,9 +3615,10 @@ def run_agent_chat(cfg):
|
|
|
3404
3615
|
raw_coding_base = _normalize_openai_base(cfg.get("codingBaseURL") or "")
|
|
3405
3616
|
raw_coding_model = (cfg.get("codingModelId") or "").strip()
|
|
3406
3617
|
# Task #108: explicit coding backend kind + API key for an OpenAI-compatible endpoint.
|
|
3407
|
-
# coding_kind: "ollama" | "openai" | ""
|
|
3408
|
-
#
|
|
3409
|
-
#
|
|
3618
|
+
# coding_kind: "ollama" | "openai" | "" — the USER'S SETTING, not the answer. What the
|
|
3619
|
+
# branches read is `coding_backend` below, resolved once from this plus the URL.
|
|
3620
|
+
# coding_api_key: the Bearer key for an OpenAI-compatible coder (empty otherwise). Used
|
|
3621
|
+
# for the code-model calls (and coding-model auto-detect), NOT the chat agent_api_key.
|
|
3410
3622
|
coding_kind = (cfg.get("codingKind") or "").strip().lower()
|
|
3411
3623
|
if coding_kind not in ("ollama", "openai"):
|
|
3412
3624
|
coding_kind = ""
|
|
@@ -3463,20 +3675,22 @@ def run_agent_chat(cfg):
|
|
|
3463
3675
|
# local MLX keeps the original tight default). Overridable via cfg.maxSteps.
|
|
3464
3676
|
# The provide_final_answer override below is only the exhaustion fallback.
|
|
3465
3677
|
#
|
|
3466
|
-
#
|
|
3467
|
-
#
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3678
|
+
# THE ONE RESOLUTION (task #117 → #123 Part B). Everything that used to ask "is this
|
|
3679
|
+
# Ollama?" or "did the user pick openai?" separately — reasoning_effort, streaming, the
|
|
3680
|
+
# #113 reasoning repairs, and this step budget — now reads `coding_backend`.
|
|
3681
|
+
#
|
|
3682
|
+
# The worker (gonext-local-worker.mjs) resolves this first, because it must decide
|
|
3683
|
+
# whether to fetch the coding API key BEFORE python starts, and passes the answer down
|
|
3684
|
+
# as codingBackend. Trust it only when we ended up on the URL it judged: the fallbacks
|
|
3685
|
+
# above can swap coding_base_url for the chat model's, which is a different server and
|
|
3686
|
+
# may well be a different KIND of server.
|
|
3687
|
+
_backend_hint = (cfg.get("codingBackend") or "").strip().lower()
|
|
3688
|
+
if _backend_hint in ("ollama", "openai", "local") and coding_base_url == raw_coding_base:
|
|
3689
|
+
coding_backend, _backend_why = _backend_hint, "resolved by the worker"
|
|
3478
3690
|
else:
|
|
3479
|
-
|
|
3691
|
+
coding_backend, _backend_why = _resolve_coding_backend(coding_kind, coding_base_url)
|
|
3692
|
+
coding_flags = _coding_backend_flags(coding_backend)
|
|
3693
|
+
_default_budget, _ws_budget = coding_flags["budget"]
|
|
3480
3694
|
try:
|
|
3481
3695
|
max_steps = int(cfg.get("maxSteps") or _default_budget)
|
|
3482
3696
|
except (TypeError, ValueError):
|
|
@@ -5326,6 +5540,14 @@ def run_agent_chat(cfg):
|
|
|
5326
5540
|
_log(f"streamed generate refused ({_clip(str(e), 200)}) → NOT falling "
|
|
5327
5541
|
"back to non-streaming; letting the retry loop back off (#114)")
|
|
5328
5542
|
raise
|
|
5543
|
+
if _is_auth_failure(e) or _is_billing_failure(e):
|
|
5544
|
+
# Same reasoning as the overload above: the credential was rejected, so
|
|
5545
|
+
# streaming was never the problem. Re-sending unstreamed would cost a
|
|
5546
|
+
# second identical request per attempt AND print "Streaming unavailable
|
|
5547
|
+
# — retrying without streaming…", which is simply untrue here.
|
|
5548
|
+
_log(f"streamed generate rejected ({_clip(str(e), 200)}) → NOT falling "
|
|
5549
|
+
"back to non-streaming; this is a credential/billing error")
|
|
5550
|
+
raise
|
|
5329
5551
|
_log(f"streamed generate failed ({e}) → falling back to non-streaming")
|
|
5330
5552
|
_emit({"type": "step",
|
|
5331
5553
|
"text": "Streaming unavailable — retrying without streaming…"})
|
|
@@ -5457,6 +5679,12 @@ def run_agent_chat(cfg):
|
|
|
5457
5679
|
_emit({"type": "step", "text": "Model output ran away — restarting this step…"})
|
|
5458
5680
|
_log(f"streamed generate: discarded {len(content)} runaway chars → empty content")
|
|
5459
5681
|
return "", role, in_tok, out_tok, reasoning_len
|
|
5682
|
+
# Task #119: smolagents passes its closing "</code>" as a stop sequence, so a
|
|
5683
|
+
# server that honors `stop` returns the block UNCLOSED. Close it here, before
|
|
5684
|
+
# anything downstream asks "did this turn produce a code block?" — otherwise a
|
|
5685
|
+
# step that called a tool correctly reads as broken. Gated on the code actually
|
|
5686
|
+
# compiling, so a genuinely dangling tag still falls through to the nudge below.
|
|
5687
|
+
content = _close_dangling_code_block(content)
|
|
5460
5688
|
# Task #111: reasoning models on an OpenAI-compatible API (e.g. Kimi K3 @ Moonshot)
|
|
5461
5689
|
# can emit the actionable <code>…</code> into the REASONING channel, leaving only
|
|
5462
5690
|
# prose + a stray tag in `content` → smolagents' parser can't find the pair. When
|
|
@@ -5522,7 +5750,11 @@ def run_agent_chat(cfg):
|
|
|
5522
5750
|
# in the trace is appended by SMOLAGENTS after we return (its stop-sequence
|
|
5523
5751
|
# handling), so the content WE see here carries no tag at all. Openai-only.
|
|
5524
5752
|
# See _code_nudge_reason for the full decision (kept pure so it can be replayed).
|
|
5525
|
-
|
|
5753
|
+
# NOTE (#119): none of these triggers may fire on a block the server merely
|
|
5754
|
+
# truncated at the "</code>" stop sequence — that is the SUCCESS shape for an
|
|
5755
|
+
# OpenAI-compatible backend, not a failure. _close_dangling_code_block runs in
|
|
5756
|
+
# _run_stream (and again inside _code_nudge_reason) so it is already excluded.
|
|
5757
|
+
_pair_re = _CODE_PAIR_RE
|
|
5526
5758
|
why = _code_nudge_reason(content, self._openai_backend)
|
|
5527
5759
|
if why:
|
|
5528
5760
|
_log(f"{why} turn (reasoning={reasoning_len} chars) → retrying once with a "
|
|
@@ -5534,7 +5766,13 @@ def run_agent_chat(cfg):
|
|
|
5534
5766
|
{"role": "user", "content": self._CODE_NOW_DIRECTIVE}
|
|
5535
5767
|
]
|
|
5536
5768
|
r_content, r_role, r_in, r_out, _ = self._run_stream(retry_kwargs)
|
|
5537
|
-
|
|
5769
|
+
# Close a stop-truncated block before judging the retry (#119). This test
|
|
5770
|
+
# used to demand a literal pair, which the openai backend cannot return —
|
|
5771
|
+
# so the retry we had just paid for was thrown away and the broken original
|
|
5772
|
+
# was sent on to smolagents. (_run_stream already closed it; the call here
|
|
5773
|
+
# is idempotent and keeps the rule visible at the point of decision.)
|
|
5774
|
+
r_has_pair = re.search(
|
|
5775
|
+
_pair_re, _close_dangling_code_block(r_content) or "", re.I) is not None
|
|
5538
5776
|
# Use the retry when it yields a real code block, or (empty original) any
|
|
5539
5777
|
# content. If the retry is still broken and we HAD a Thought, keep the
|
|
5540
5778
|
# original and let smolagents' own retry take it from here — no regression.
|
|
@@ -5545,7 +5783,13 @@ def run_agent_chat(cfg):
|
|
|
5545
5783
|
out_tok += r_out
|
|
5546
5784
|
if stop_sequences and not self.supports_stop_parameter:
|
|
5547
5785
|
from smolagents.models import remove_content_after_stop_sequences
|
|
5786
|
+
# For a server that can't honor `stop`, smolagents emulates it by splitting
|
|
5787
|
+
# on each stop sequence and keeping split[0] — and "</code>" IS one of those
|
|
5788
|
+
# sequences, so this CUTS THE CLOSER back off a block that had one. Re-close
|
|
5789
|
+
# afterwards (#119) so both kinds of backend hand back the same canonical
|
|
5790
|
+
# shape; without it the emulated path re-creates the exact bug we just fixed.
|
|
5548
5791
|
content = remove_content_after_stop_sequences(content, stop_sequences)
|
|
5792
|
+
content = _close_dangling_code_block(content)
|
|
5549
5793
|
return ChatMessage(
|
|
5550
5794
|
role=role,
|
|
5551
5795
|
content=content,
|
|
@@ -5650,8 +5894,47 @@ def run_agent_chat(cfg):
|
|
|
5650
5894
|
# for this server, e.g. an MLX-style name against Ollama) —
|
|
5651
5895
|
# deterministic, so retrying is pointless. Fail fast with the
|
|
5652
5896
|
# server's actual model list so the user can fix Settings.
|
|
5897
|
+
# A rejected CREDENTIAL is deterministic — retrying re-sends the
|
|
5898
|
+
# same bad key, and letting it fall through to the turn-level
|
|
5899
|
+
# handler would degrade to _plain_reply, i.e. the chat model
|
|
5900
|
+
# answering confidently about work that never ran (#94's failure
|
|
5901
|
+
# mode). Fail fast with a message that says WHICH key went out:
|
|
5902
|
+
# python uses `coding_api_key or agent_api_key`, so an unset coding
|
|
5903
|
+
# key silently authenticates a cloud endpoint with the LOCAL agent
|
|
5904
|
+
# key (the Auto+cloud path in #117) — a completely different fix
|
|
5905
|
+
# from "your coding key is wrong". The key itself is never echoed.
|
|
5906
|
+
if _is_auth_failure(e) or _is_billing_failure(e):
|
|
5907
|
+
_host = _host_of(coding_base_url)
|
|
5908
|
+
if _is_billing_failure(e):
|
|
5909
|
+
raise _AgentConfigError(
|
|
5910
|
+
f"The agent coding backend ({_host}) accepted the key "
|
|
5911
|
+
"but refused the request for BILLING reasons (out of "
|
|
5912
|
+
"credit / quota exhausted). Nothing ran. Top up the "
|
|
5913
|
+
"account, or point 'Agent coding model' at another "
|
|
5914
|
+
"backend in Settings → Agent."
|
|
5915
|
+
) from e
|
|
5916
|
+
if coding_api_key:
|
|
5917
|
+
raise _AgentConfigError(
|
|
5918
|
+
f"The agent coding backend ({_host}) REJECTED the "
|
|
5919
|
+
"coding API key. Nothing ran. Re-enter 'Agent coding "
|
|
5920
|
+
"model API key' in Settings → Agent (the stored key is "
|
|
5921
|
+
"write-only, so a stale one can't be inspected — just "
|
|
5922
|
+
"save a fresh one)."
|
|
5923
|
+
) from e
|
|
5924
|
+
raise _AgentConfigError(
|
|
5925
|
+
f"The agent coding backend ({_host}) rejected the request: "
|
|
5926
|
+
"NO coding API key was sent, so the local agent key was "
|
|
5927
|
+
"used instead. Set 'Coding backend' to OpenAI-compatible "
|
|
5928
|
+
"in Settings → Agent and enter the API key — the key field "
|
|
5929
|
+
"only appears once that backend is selected."
|
|
5930
|
+
) from e
|
|
5653
5931
|
if "404" in emsg and "not found" in emsg.lower():
|
|
5654
|
-
|
|
5932
|
+
# Probe with the CODING key when there is one: using the agent
|
|
5933
|
+
# key here 401s against a cloud coder, so the "This server has:
|
|
5934
|
+
# …" hint — the only useful part of this error — silently
|
|
5935
|
+
# vanished exactly when it was needed.
|
|
5936
|
+
ids = _list_model_ids(coding_base_url,
|
|
5937
|
+
coding_api_key or agent_api_key)
|
|
5655
5938
|
have = f" This server has: {', '.join(ids)}." if ids else ""
|
|
5656
5939
|
raise _AgentConfigError(
|
|
5657
5940
|
f"The agent coding model {coding_model_id!r} does not "
|
|
@@ -6094,25 +6377,16 @@ def run_agent_chat(cfg):
|
|
|
6094
6377
|
# the live test — minutes per step on a slow GPU — so disable it explicitly.
|
|
6095
6378
|
# Sniffed per-server because MLX might reject the unknown param.
|
|
6096
6379
|
extra_model_kwargs = {}
|
|
6097
|
-
#
|
|
6098
|
-
#
|
|
6099
|
-
|
|
6100
|
-
if coding_kind == "ollama":
|
|
6101
|
-
coding_is_ollama = True
|
|
6102
|
-
elif coding_kind == "openai":
|
|
6103
|
-
coding_is_ollama = False
|
|
6104
|
-
else:
|
|
6105
|
-
coding_is_ollama = _is_ollama_server(coding_base_url)
|
|
6106
|
-
if coding_is_ollama:
|
|
6380
|
+
# `coding_backend` was resolved once, above, and `coding_flags` derives everything
|
|
6381
|
+
# from it — do NOT re-derive any of this from the kind or a URL sniff here.
|
|
6382
|
+
if coding_flags["ollama_tweaks"]:
|
|
6107
6383
|
_log("coding server is Ollama → reasoning_effort='none' (disable thinking) + streaming")
|
|
6108
6384
|
extra_model_kwargs["reasoning_effort"] = "none"
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
_log(f"coding backend: kind={coding_kind or 'auto'} ollama={coding_is_ollama} "
|
|
6115
|
-
f"stream={_stream_coder} key={'yes' if coding_api_key else 'no'}")
|
|
6385
|
+
_stream_coder = coding_flags["stream"]
|
|
6386
|
+
_log(f"coding backend: {coding_backend} ({_backend_why}; "
|
|
6387
|
+
f"kind={coding_kind or 'auto'}, {_host_of(coding_base_url)}) "
|
|
6388
|
+
f"stream={_stream_coder} steps={max_steps} "
|
|
6389
|
+
f"key={'yes' if coding_api_key else 'no'}")
|
|
6116
6390
|
model = _LoggingModel(
|
|
6117
6391
|
model_id=coding_model_id,
|
|
6118
6392
|
api_base=coding_base_url,
|
|
@@ -6125,9 +6399,12 @@ def run_agent_chat(cfg):
|
|
|
6125
6399
|
# Task #107: persist each code-model response to Mongo when the user opted in.
|
|
6126
6400
|
save_full_response=save_full_response,
|
|
6127
6401
|
# Task #113: enables the reasoning-model repairs (prose-only nudge, parse-error
|
|
6128
|
-
# sanitizing, no-code breaker).
|
|
6129
|
-
# never take those branches.
|
|
6130
|
-
|
|
6402
|
+
# sanitizing, no-code breaker). Gated to the OpenAI-compatible backend — Ollama
|
|
6403
|
+
# and local MLX never take those branches. #113 gated this on the EXPLICIT
|
|
6404
|
+
# kind; the intent (don't touch Ollama) is unchanged, but an Auto+cloud config
|
|
6405
|
+
# now resolves to "openai" instead of "local", so it finally gets the repairs
|
|
6406
|
+
# it was always meant to have.
|
|
6407
|
+
openai_backend=coding_flags["openai_repairs"],
|
|
6131
6408
|
# Cap each HTTP attempt at 600s so a genuinely slow single call (a
|
|
6132
6409
|
# remote M6000/Vulkan box doing a cold ~4-5min prompt-eval) can finish
|
|
6133
6410
|
# on the first try, while a truly hung request still fails into OUR
|
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.332",
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"gonext": "./gonext-repl.mjs"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
+
"test": "python3 -m unittest discover tests -v",
|
|
20
21
|
"deploy:local": "npm install -g .",
|
|
21
22
|
"run": "gonext-local-worker",
|
|
22
23
|
"publish:org": "npm publish --access public"
|