@tiens.nguyen/gonext-local-worker 1.0.283 → 1.0.284
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 +4 -0
- package/gonext_agent_chat.py +90 -6
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1893,6 +1893,10 @@ async function runAgentChatJob(job) {
|
|
|
1893
1893
|
agentModelId: payload?.agentModelId ?? "",
|
|
1894
1894
|
codingBaseURL: payload?.codingBaseURL ?? "",
|
|
1895
1895
|
codingModelId: payload?.codingModelId ?? "",
|
|
1896
|
+
// Dedicated SEARCH model (#105): when set, web_search synthesizes its results into a
|
|
1897
|
+
// cited answer with this model instead of returning raw read pages. Empty = no synth.
|
|
1898
|
+
searchBaseURL: payload?.searchBaseURL ?? "",
|
|
1899
|
+
searchModelId: payload?.searchModelId ?? "",
|
|
1896
1900
|
tools: payload?.tools ?? ["http_request"],
|
|
1897
1901
|
// 0 = "not explicitly set": python defaults to 5, but may auto-raise to 12 when
|
|
1898
1902
|
// workspaces are registered. Passing a literal 5 here would look user-set and
|
package/gonext_agent_chat.py
CHANGED
|
@@ -10,6 +10,8 @@ Reads on stdin:
|
|
|
10
10
|
"agentModelId": str,
|
|
11
11
|
"codingBaseURL": str, # optional: dedicated coding/reasoning model for the
|
|
12
12
|
"codingModelId": str, # CodeAgent's tool-use loop; empty = reuse agentModelId
|
|
13
|
+
"searchBaseURL": str, # optional: dedicated SEARCH model that synthesizes
|
|
14
|
+
"searchModelId": str, # web_search results into a cited answer; empty = none
|
|
13
15
|
"tools": ["http_request"], # v1: only http_request
|
|
14
16
|
"maxSteps": int # multi-step ReAct budget; default 5
|
|
15
17
|
}
|
|
@@ -606,12 +608,69 @@ def _read_pages(ranked, query, per_page_chars=1500, per_timeout=5, overall=6):
|
|
|
606
608
|
return out
|
|
607
609
|
|
|
608
610
|
|
|
609
|
-
def
|
|
611
|
+
def _synthesize_search(query, context, base_url, model_id, api_key="local",
|
|
612
|
+
stall_timeout=20, overall_deadline=45):
|
|
613
|
+
"""Search model (#105): ask a configured OpenAI-compatible model to write a concise,
|
|
614
|
+
cited answer FROM the already-read search content — so the (slow) coding model doesn't
|
|
615
|
+
reason over raw pages (the Perplexity pattern).
|
|
616
|
+
|
|
617
|
+
STREAMS with a per-READ timeout (httpx `timeout` fires when NO bytes arrive within
|
|
618
|
+
`stall_timeout`) — that is a genuine FIRST-TOKEN deadline during prompt-eval and an
|
|
619
|
+
inter-token watchdog after. This is the fix for the old whole-response `timeout=45`
|
|
620
|
+
that, under MLX contention, let the client hang until it aborted → APITimeoutError +
|
|
621
|
+
an MLX broken-pipe. Returns "" on ANY failure so the caller falls back to the read
|
|
622
|
+
content — never worse than #104."""
|
|
623
|
+
from openai import OpenAI
|
|
624
|
+
import time as _t
|
|
625
|
+
client = OpenAI(base_url=base_url, api_key=api_key or "local", max_retries=0,
|
|
626
|
+
timeout=stall_timeout)
|
|
627
|
+
parts = []
|
|
628
|
+
t0 = _t.time()
|
|
629
|
+
try:
|
|
630
|
+
stream = client.chat.completions.create(
|
|
631
|
+
model=model_id or "default_model",
|
|
632
|
+
messages=[
|
|
633
|
+
{"role": "system", "content": (
|
|
634
|
+
"You are a research assistant. Using ONLY the search results provided, "
|
|
635
|
+
"write a concise, factual answer to the user's query. Cite sources inline "
|
|
636
|
+
"as [n] using their numbers. Preserve any table / schedule rows verbatim. "
|
|
637
|
+
"If the results do not contain the answer, say so plainly — never invent "
|
|
638
|
+
"facts, dates, names, or URLs."
|
|
639
|
+
)},
|
|
640
|
+
{"role": "user",
|
|
641
|
+
"content": f"Query: {query}\n\nSearch results:\n{context}\n\nAnswer:"},
|
|
642
|
+
],
|
|
643
|
+
max_tokens=700,
|
|
644
|
+
temperature=0.2,
|
|
645
|
+
stream=True,
|
|
646
|
+
)
|
|
647
|
+
for chunk in stream:
|
|
648
|
+
choices = getattr(chunk, "choices", None)
|
|
649
|
+
delta = getattr(choices[0], "delta", None) if choices else None
|
|
650
|
+
piece = getattr(delta, "content", None) if delta else None
|
|
651
|
+
if piece:
|
|
652
|
+
parts.append(piece)
|
|
653
|
+
if (_t.time() - t0) > overall_deadline: # generation ran long → keep partial
|
|
654
|
+
_log(f"search-model synthesis: {overall_deadline}s cap hit → using partial")
|
|
655
|
+
try:
|
|
656
|
+
stream.close()
|
|
657
|
+
except Exception: # noqa: BLE001
|
|
658
|
+
pass
|
|
659
|
+
break
|
|
660
|
+
return "".join(parts).strip()
|
|
661
|
+
except Exception as e: # noqa: BLE001
|
|
662
|
+
_log(f"search-model synthesis failed ({type(e).__name__}: {e}); using read output")
|
|
663
|
+
return "".join(parts).strip() # partial (if any) is still useful; else caller falls back
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _web_search_impl(query, max_results=5, searxng_url="",
|
|
667
|
+
search_base_url="", search_model_id=""):
|
|
610
668
|
"""Keyless web search that READS for the model (tasks #103 + #104): run DuckDuckGo-HTML
|
|
611
669
|
+ SearXNG (if configured) + Wikipedia CONCURRENTLY, merge/dedupe/rank by cross-engine
|
|
612
670
|
agreement, surface any SERP answer box, then (unless GONEXT_SEARCH_READ=0) READ the top
|
|
613
|
-
pages in parallel
|
|
614
|
-
|
|
671
|
+
pages in parallel. If a SEARCH MODEL is configured (search_base_url/search_model_id, #105)
|
|
672
|
+
it then SYNTHESIZES a cited answer from that read content — so the slow coding model gets
|
|
673
|
+
a clean answer, not raw pages. Model-free otherwise; never fabricates.
|
|
615
674
|
"""
|
|
616
675
|
import os
|
|
617
676
|
import time as _t
|
|
@@ -622,8 +681,9 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
622
681
|
return "web_search: empty query."
|
|
623
682
|
searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
|
|
624
683
|
|
|
625
|
-
# Short TTL cache (identical query within a turn).
|
|
626
|
-
|
|
684
|
+
# Short TTL cache (identical query within a turn). Include the search model so a
|
|
685
|
+
# synthesized answer isn't served after the search-model config changes.
|
|
686
|
+
ckey = (q.lower(), searxng, search_base_url, search_model_id)
|
|
627
687
|
hit = _WEB_SEARCH_CACHE.get(ckey)
|
|
628
688
|
if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
|
|
629
689
|
return hit[1]
|
|
@@ -728,6 +788,16 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
728
788
|
lines.append(head + (f"\n{body}" if body else ""))
|
|
729
789
|
parts.append("\n".join(lines))
|
|
730
790
|
out = "\n\n".join(parts)
|
|
791
|
+
|
|
792
|
+
# Search model (#105): if configured, have it write the cited answer FROM the read
|
|
793
|
+
# content above, so the coding model receives a clean answer instead of raw pages.
|
|
794
|
+
# Best-effort — any failure keeps the #104 read output (never worse).
|
|
795
|
+
if search_base_url and search_model_id and (answers or ranked):
|
|
796
|
+
synth = _synthesize_search(q, out, search_base_url, search_model_id)
|
|
797
|
+
if synth:
|
|
798
|
+
srcs = "\n".join(f"[{i}] {e['url']}" for i, e in enumerate(ranked, 1))
|
|
799
|
+
out = f"Answer:\n{synth}" + (f"\n\nSources:\n{srcs}" if srcs else "")
|
|
800
|
+
|
|
731
801
|
_WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
|
|
732
802
|
return out
|
|
733
803
|
|
|
@@ -3091,6 +3161,14 @@ def run_agent_chat(cfg):
|
|
|
3091
3161
|
f"start model={agent_model_id!r} base={agent_base_url!r} "
|
|
3092
3162
|
f"codeModel={coding_model_id!r} codeBase={coding_base_url!r} maxSteps={max_steps}"
|
|
3093
3163
|
)
|
|
3164
|
+
# Search model (#105): diagnostics only — the web_search tool reads it from cfg directly.
|
|
3165
|
+
_search_base = (cfg.get("searchBaseURL") or "").strip()
|
|
3166
|
+
_search_model = (cfg.get("searchModelId") or "").strip()
|
|
3167
|
+
if _search_base and _search_model:
|
|
3168
|
+
_log(f"search model: {_search_model!r} @ {_search_base} "
|
|
3169
|
+
"(web_search synthesizes a cited answer with it)")
|
|
3170
|
+
else:
|
|
3171
|
+
_log("search model: none (web_search returns read page content, not a synthesized answer)")
|
|
3094
3172
|
|
|
3095
3173
|
# Build the task from the conversation history. We include the FULL conversation
|
|
3096
3174
|
# (both user AND assistant turns) so the agent remembers what it already did —
|
|
@@ -4180,7 +4258,13 @@ def run_agent_chat(cfg):
|
|
|
4180
4258
|
_emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
|
|
4181
4259
|
# SearXNG URL (task #103): from Settings (cfg.searxngUrl) if the API sends it, else
|
|
4182
4260
|
# the GONEXT_SEARXNG_URL env fallback inside _web_search_impl. Keyless either way.
|
|
4183
|
-
|
|
4261
|
+
# Search model (#105): when configured, web_search synthesizes a cited answer with it.
|
|
4262
|
+
result = _web_search_impl(
|
|
4263
|
+
query,
|
|
4264
|
+
searxng_url=(cfg.get("searxngUrl") or "").strip(),
|
|
4265
|
+
search_base_url=(cfg.get("searchBaseURL") or "").strip(),
|
|
4266
|
+
search_model_id=(cfg.get("searchModelId") or "").strip(),
|
|
4267
|
+
)
|
|
4184
4268
|
_log(f"web_search {query[:60]!r} → {result[:80]}")
|
|
4185
4269
|
_last_obs["text"] = result
|
|
4186
4270
|
if result and not result.startswith("Error"):
|
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.284",
|
|
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",
|