@tiens.nguyen/gonext-local-worker 1.0.283 → 1.0.285
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 +109 -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]
|
|
@@ -689,6 +749,19 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
689
749
|
order += 1
|
|
690
750
|
ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
|
|
691
751
|
|
|
752
|
+
# Backend attribution (diagnostics): which engines actually returned results this call,
|
|
753
|
+
# so the worker log shows whether SearXNG contributed. searxng=off means GONEXT_SEARXNG_URL
|
|
754
|
+
# wasn't seen by python; searxng=on(0) means it was reached but returned nothing.
|
|
755
|
+
_log(
|
|
756
|
+
"web_search backends → "
|
|
757
|
+
f"searxng={'on' if searxng else 'off'}"
|
|
758
|
+
+ (f"({len(results_by.get('searxng', []))})" if searxng else "")
|
|
759
|
+
+ f" ddg({len(results_by.get('ddg', []))})"
|
|
760
|
+
+ (f" wikipedia({len(results_by.get('wikipedia', []))})" if not searxng else "")
|
|
761
|
+
+ f" answers({len(answers)}) → {len(ranked)} ranked"
|
|
762
|
+
+ (f" [searxng={searxng}]" if searxng else "")
|
|
763
|
+
)
|
|
764
|
+
|
|
692
765
|
if not summary and not ranked and not answers:
|
|
693
766
|
return (
|
|
694
767
|
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
@@ -728,6 +801,16 @@ def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
|
728
801
|
lines.append(head + (f"\n{body}" if body else ""))
|
|
729
802
|
parts.append("\n".join(lines))
|
|
730
803
|
out = "\n\n".join(parts)
|
|
804
|
+
|
|
805
|
+
# Search model (#105): if configured, have it write the cited answer FROM the read
|
|
806
|
+
# content above, so the coding model receives a clean answer instead of raw pages.
|
|
807
|
+
# Best-effort — any failure keeps the #104 read output (never worse).
|
|
808
|
+
if search_base_url and search_model_id and (answers or ranked):
|
|
809
|
+
synth = _synthesize_search(q, out, search_base_url, search_model_id)
|
|
810
|
+
if synth:
|
|
811
|
+
srcs = "\n".join(f"[{i}] {e['url']}" for i, e in enumerate(ranked, 1))
|
|
812
|
+
out = f"Answer:\n{synth}" + (f"\n\nSources:\n{srcs}" if srcs else "")
|
|
813
|
+
|
|
731
814
|
_WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
|
|
732
815
|
return out
|
|
733
816
|
|
|
@@ -3091,6 +3174,20 @@ def run_agent_chat(cfg):
|
|
|
3091
3174
|
f"start model={agent_model_id!r} base={agent_base_url!r} "
|
|
3092
3175
|
f"codeModel={coding_model_id!r} codeBase={coding_base_url!r} maxSteps={max_steps}"
|
|
3093
3176
|
)
|
|
3177
|
+
# SearXNG (task #103): diagnostics — confirms python SEES the URL (from cfg.searxngUrl or
|
|
3178
|
+
# the GONEXT_SEARXNG_URL env). If this logs 'none', web_search falls back to keyless DDG.
|
|
3179
|
+
import os as _os
|
|
3180
|
+
_searxng_url = ((cfg.get("searxngUrl") or "").strip()
|
|
3181
|
+
or (_os.environ.get("GONEXT_SEARXNG_URL") or "").strip())
|
|
3182
|
+
_log(f"searxng: {_searxng_url or 'none (keyless DDG + Wikipedia only)'}")
|
|
3183
|
+
# Search model (#105): diagnostics only — the web_search tool reads it from cfg directly.
|
|
3184
|
+
_search_base = (cfg.get("searchBaseURL") or "").strip()
|
|
3185
|
+
_search_model = (cfg.get("searchModelId") or "").strip()
|
|
3186
|
+
if _search_base and _search_model:
|
|
3187
|
+
_log(f"search model: {_search_model!r} @ {_search_base} "
|
|
3188
|
+
"(web_search synthesizes a cited answer with it)")
|
|
3189
|
+
else:
|
|
3190
|
+
_log("search model: none (web_search returns read page content, not a synthesized answer)")
|
|
3094
3191
|
|
|
3095
3192
|
# Build the task from the conversation history. We include the FULL conversation
|
|
3096
3193
|
# (both user AND assistant turns) so the agent remembers what it already did —
|
|
@@ -4180,7 +4277,13 @@ def run_agent_chat(cfg):
|
|
|
4180
4277
|
_emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
|
|
4181
4278
|
# SearXNG URL (task #103): from Settings (cfg.searxngUrl) if the API sends it, else
|
|
4182
4279
|
# the GONEXT_SEARXNG_URL env fallback inside _web_search_impl. Keyless either way.
|
|
4183
|
-
|
|
4280
|
+
# Search model (#105): when configured, web_search synthesizes a cited answer with it.
|
|
4281
|
+
result = _web_search_impl(
|
|
4282
|
+
query,
|
|
4283
|
+
searxng_url=(cfg.get("searxngUrl") or "").strip(),
|
|
4284
|
+
search_base_url=(cfg.get("searchBaseURL") or "").strip(),
|
|
4285
|
+
search_model_id=(cfg.get("searchModelId") or "").strip(),
|
|
4286
|
+
)
|
|
4184
4287
|
_log(f"web_search {query[:60]!r} → {result[:80]}")
|
|
4185
4288
|
_last_obs["text"] = result
|
|
4186
4289
|
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.285",
|
|
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",
|