@tiens.nguyen/gonext-local-worker 1.0.277 → 1.0.280
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 +0 -3
- package/gonext_agent_chat.py +24 -157
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1893,9 +1893,6 @@ async function runAgentChatJob(job) {
|
|
|
1893
1893
|
agentModelId: payload?.agentModelId ?? "",
|
|
1894
1894
|
codingBaseURL: payload?.codingBaseURL ?? "",
|
|
1895
1895
|
codingModelId: payload?.codingModelId ?? "",
|
|
1896
|
-
// Optional search model (task #105): synthesizes web_search results. Empty = no synthesis.
|
|
1897
|
-
searchBaseURL: payload?.searchBaseURL ?? "",
|
|
1898
|
-
searchModelId: payload?.searchModelId ?? "",
|
|
1899
1896
|
tools: payload?.tools ?? ["http_request"],
|
|
1900
1897
|
// 0 = "not explicitly set": python defaults to 5, but may auto-raise to 12 when
|
|
1901
1898
|
// workspaces are registered. Passing a literal 5 here would look user-set and
|
package/gonext_agent_chat.py
CHANGED
|
@@ -440,35 +440,24 @@ def _bk_ddg_html(q, timeout=6):
|
|
|
440
440
|
|
|
441
441
|
|
|
442
442
|
def _bk_searxng(q, base, timeout=6):
|
|
443
|
-
"""Self-hosted SearXNG JSON API
|
|
444
|
-
`answers` = SearXNG's aggregated zero-click ANSWERS + INFOBOXES (the 'answer box' / knowledge
|
|
445
|
-
panel a human sees on a SERP) — surfaced first so one search often needs no page read (#104)."""
|
|
443
|
+
"""Self-hosted SearXNG JSON API → aggregated results [{title,snippet,url}] (keyless)."""
|
|
446
444
|
from urllib.parse import quote
|
|
447
445
|
base = (base or "").strip().rstrip("/")
|
|
448
446
|
if not base:
|
|
449
|
-
return
|
|
447
|
+
return []
|
|
450
448
|
data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
|
|
451
|
-
|
|
449
|
+
out = []
|
|
452
450
|
if isinstance(data, dict):
|
|
453
451
|
for r in (data.get("results") or []):
|
|
454
452
|
url = (r.get("url") or "").strip()
|
|
455
453
|
if not url:
|
|
456
454
|
continue
|
|
457
|
-
|
|
455
|
+
out.append({
|
|
458
456
|
"title": _search_strip(r.get("title") or url)[:120],
|
|
459
457
|
"snippet": _search_strip(r.get("content") or ""),
|
|
460
458
|
"url": url,
|
|
461
459
|
})
|
|
462
|
-
|
|
463
|
-
txt = _search_strip(a if isinstance(a, str) else (a.get("answer") or a.get("content") or ""))
|
|
464
|
-
if txt:
|
|
465
|
-
answers.append(txt[:600])
|
|
466
|
-
for ib in (data.get("infoboxes") or []):
|
|
467
|
-
if isinstance(ib, dict):
|
|
468
|
-
txt = _search_strip(ib.get("content") or "")
|
|
469
|
-
if txt:
|
|
470
|
-
answers.append(txt[:600])
|
|
471
|
-
return {"results": results, "answers": answers}
|
|
460
|
+
return out
|
|
472
461
|
|
|
473
462
|
|
|
474
463
|
def _bk_wikipedia(q, timeout=6, n=5):
|
|
@@ -508,87 +497,7 @@ def _bk_ddg_ia_summary(q, timeout=6):
|
|
|
508
497
|
return ("", "")
|
|
509
498
|
|
|
510
499
|
|
|
511
|
-
def
|
|
512
|
-
"""Keep the QUERY-RELEVANT paragraphs of a page (task #104): split into paragraphs,
|
|
513
|
-
score each by distinct query-term overlap, keep the highest-scoring ones up to max_chars,
|
|
514
|
-
then restore original order. Boilerplate/nav is already gone (via _html_to_text); this
|
|
515
|
-
picks the part that actually answers the query so the read stays small + on-signal."""
|
|
516
|
-
terms = {t for t in re.findall(r"[a-z0-9]{3,}", (query or "").lower())}
|
|
517
|
-
paras = [p.strip() for p in re.split(r"\n\s*\n|\n", text or "") if len(p.strip()) > 20]
|
|
518
|
-
if not paras:
|
|
519
|
-
return (text or "")[:max_chars].strip()
|
|
520
|
-
if not terms:
|
|
521
|
-
return "\n".join(paras)[:max_chars].strip()
|
|
522
|
-
scored = [(sum(1 for t in terms if t in p.lower()), i, p) for i, p in enumerate(paras)]
|
|
523
|
-
chosen, total = [], 0
|
|
524
|
-
for score, i, p in sorted(scored, key=lambda x: (-x[0], x[1])):
|
|
525
|
-
if score == 0 and chosen: # stop once we're into zero-signal paragraphs
|
|
526
|
-
break
|
|
527
|
-
if chosen and total + len(p) > max_chars:
|
|
528
|
-
break
|
|
529
|
-
chosen.append((i, p))
|
|
530
|
-
total += len(p) + 1
|
|
531
|
-
chosen.sort()
|
|
532
|
-
return "\n".join(p for _, p in chosen)[:max_chars].strip()
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def _read_pages(urls, query, per_page_chars=1600, per_timeout=5):
|
|
536
|
-
"""Fetch several result pages IN PARALLEL and return {url: condensed_readable_text}.
|
|
537
|
-
The 'read' half of search-and-read (task #104) — reuses _fetch_page_impl + _html_to_text.
|
|
538
|
-
Skips non-HTML/failed pages (the snippet stays as the fallback)."""
|
|
539
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
540
|
-
import time as _t
|
|
541
|
-
|
|
542
|
-
def _read_one(u):
|
|
543
|
-
status, ctype, raw = _fetch_page_impl(u, timeout=per_timeout, max_bytes=300000)
|
|
544
|
-
if not status or status >= 400:
|
|
545
|
-
return u, ""
|
|
546
|
-
if ctype and ("html" not in ctype and "text" not in ctype):
|
|
547
|
-
return u, "" # skip pdf/json/binary — the model can fetch_url those explicitly
|
|
548
|
-
text = _html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
|
|
549
|
-
return u, _condense_for_query(text, query, per_page_chars)
|
|
550
|
-
|
|
551
|
-
out = {}
|
|
552
|
-
if not urls:
|
|
553
|
-
return out
|
|
554
|
-
with ThreadPoolExecutor(max_workers=len(urls)) as ex:
|
|
555
|
-
futs = {ex.submit(_read_one, u): u for u in urls}
|
|
556
|
-
end = _t.time() + per_timeout + 3
|
|
557
|
-
for fut in list(futs):
|
|
558
|
-
try:
|
|
559
|
-
uu, txt = fut.result(timeout=max(0.0, end - _t.time()))
|
|
560
|
-
if txt:
|
|
561
|
-
out[uu] = txt
|
|
562
|
-
except Exception: # noqa: BLE001
|
|
563
|
-
continue
|
|
564
|
-
return out
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
def _synthesize_search(query, context, base_url, model_id, api_key="local"):
|
|
568
|
-
"""Search model (task #105): ask a configured OpenAI-compatible model to write a concise,
|
|
569
|
-
cited answer from the already-fetched search results — so the (slow) code model doesn't
|
|
570
|
-
have to read/reason over raw pages. Returns "" on any failure (caller falls back)."""
|
|
571
|
-
from openai import OpenAI
|
|
572
|
-
client = OpenAI(base_url=base_url, api_key=api_key or "local", max_retries=0, timeout=45)
|
|
573
|
-
resp = _chat_create(
|
|
574
|
-
client,
|
|
575
|
-
model=model_id or "default_model",
|
|
576
|
-
messages=[
|
|
577
|
-
{"role": "system", "content": (
|
|
578
|
-
"You are a research assistant. Using ONLY the search results provided, write a "
|
|
579
|
-
"concise, factual answer to the user's query. Cite sources inline as [n] using "
|
|
580
|
-
"their numbers. If the results do not contain the answer, say so plainly — never "
|
|
581
|
-
"invent facts, dates, names, or URLs."
|
|
582
|
-
)},
|
|
583
|
-
{"role": "user", "content": f"Query: {query}\n\nSearch results:\n{context}\n\nAnswer:"},
|
|
584
|
-
],
|
|
585
|
-
max_tokens=700,
|
|
586
|
-
temperature=0.2,
|
|
587
|
-
)
|
|
588
|
-
return (resp.choices[0].message.content or "").strip()
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", search_model_id=""):
|
|
500
|
+
def _web_search_impl(query, max_results=5, searxng_url=""):
|
|
592
501
|
"""Parallel keyless web search (task #103): run DuckDuckGo-HTML + SearXNG (if configured)
|
|
593
502
|
+ Wikipedia CONCURRENTLY with a short deadline, merge/dedupe/rank by cross-engine
|
|
594
503
|
agreement, and return a short SUMMARY (when available) + a numbered list of the top
|
|
@@ -603,9 +512,8 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
|
|
|
603
512
|
return "web_search: empty query."
|
|
604
513
|
searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
|
|
605
514
|
|
|
606
|
-
# Short TTL cache (identical query within a turn).
|
|
607
|
-
|
|
608
|
-
ckey = (q.lower(), searxng, search_base_url, search_model_id)
|
|
515
|
+
# Short TTL cache (identical query within a turn).
|
|
516
|
+
ckey = (q.lower(), searxng)
|
|
609
517
|
hit = _WEB_SEARCH_CACHE.get(ckey)
|
|
610
518
|
if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
|
|
611
519
|
return hit[1]
|
|
@@ -621,7 +529,6 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
|
|
|
621
529
|
tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
|
|
622
530
|
|
|
623
531
|
results_by = {}
|
|
624
|
-
answers = [] # zero-click 'answer box' text (SearXNG + DDG-IA)
|
|
625
532
|
summary, summary_src = "", ""
|
|
626
533
|
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
|
|
627
534
|
futs = {ex.submit(fn): name for name, fn in tasks}
|
|
@@ -633,13 +540,8 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
|
|
|
633
540
|
continue
|
|
634
541
|
if name == "summary":
|
|
635
542
|
summary, summary_src = r
|
|
636
|
-
elif name == "searxng":
|
|
637
|
-
results_by["searxng"] = r.get("results", [])
|
|
638
|
-
answers.extend(r.get("answers", []))
|
|
639
543
|
else:
|
|
640
544
|
results_by[name] = r
|
|
641
|
-
if summary: # DDG instant answer is an 'answer box' too
|
|
642
|
-
answers.insert(0, summary + (f" (source: {summary_src})" if summary_src else ""))
|
|
643
545
|
|
|
644
546
|
# Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
|
|
645
547
|
merged = {}
|
|
@@ -661,55 +563,24 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
|
|
|
661
563
|
order += 1
|
|
662
564
|
ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
|
|
663
565
|
|
|
664
|
-
if not
|
|
566
|
+
if not summary and not ranked:
|
|
665
567
|
return (
|
|
666
568
|
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
667
569
|
"do NOT invent an answer or a URL."
|
|
668
570
|
)
|
|
669
571
|
|
|
670
|
-
# Search-AND-read (task #104): READ the top pages here (in parallel) so the model gets
|
|
671
|
-
# answer-ready content from ONE call and rarely needs a follow-up fetch_url — the big win
|
|
672
|
-
# when the reasoning model is slow (each avoided round-trip saves a full model step).
|
|
673
|
-
read_enabled = (os.environ.get("GONEXT_SEARCH_READ", "1").strip() != "0")
|
|
674
|
-
try:
|
|
675
|
-
read_k = max(0, int(os.environ.get("GONEXT_SEARCH_READ_K", "3")))
|
|
676
|
-
except ValueError:
|
|
677
|
-
read_k = 3
|
|
678
|
-
try:
|
|
679
|
-
page_chars = max(400, int(os.environ.get("GONEXT_SEARCH_PAGE_CHARS", "1600")))
|
|
680
|
-
except ValueError:
|
|
681
|
-
page_chars = 1600
|
|
682
|
-
page_texts = {}
|
|
683
|
-
if read_enabled and read_k and ranked:
|
|
684
|
-
page_texts = _read_pages([e["url"] for e in ranked[:read_k]], q,
|
|
685
|
-
per_page_chars=page_chars, per_timeout=5)
|
|
686
|
-
|
|
687
572
|
parts = []
|
|
688
|
-
if
|
|
689
|
-
parts.append(
|
|
573
|
+
if summary:
|
|
574
|
+
parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
|
|
690
575
|
if ranked:
|
|
691
|
-
|
|
692
|
-
"call it only to read a specific page in full):")
|
|
693
|
-
blocks = [header]
|
|
576
|
+
lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
|
|
694
577
|
for i, e in enumerate(ranked, 1):
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
578
|
+
line = f"{i}. {e['title']}"
|
|
579
|
+
if e["snippet"]:
|
|
580
|
+
line += f" — {e['snippet'][:160]}"
|
|
581
|
+
lines.append(line + f"\n {e['url']}")
|
|
582
|
+
parts.append("\n".join(lines))
|
|
699
583
|
out = "\n\n".join(parts)
|
|
700
|
-
|
|
701
|
-
# Search model (task #105): if configured, have it write the answer FROM the results we
|
|
702
|
-
# just read — so the code model gets a clean cited answer, not raw pages. Best-effort:
|
|
703
|
-
# any failure falls back to the read output above.
|
|
704
|
-
if search_base_url and search_model_id and (answers or ranked):
|
|
705
|
-
try:
|
|
706
|
-
synth = _synthesize_search(q, out, search_base_url, search_model_id)
|
|
707
|
-
if synth:
|
|
708
|
-
srcs = "\n".join(f"[{i}] {e['url']}" for i, e in enumerate(ranked, 1))
|
|
709
|
-
out = f"Answer:\n{synth}" + (f"\n\nSources:\n{srcs}" if srcs else "")
|
|
710
|
-
except Exception as e: # noqa: BLE001
|
|
711
|
-
_log(f"search-model synthesis failed ({type(e).__name__}: {e}); using read output")
|
|
712
|
-
|
|
713
584
|
_WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
|
|
714
585
|
return out
|
|
715
586
|
|
|
@@ -4138,13 +4009,13 @@ def run_agent_chat(cfg):
|
|
|
4138
4009
|
|
|
4139
4010
|
@tool
|
|
4140
4011
|
def web_search(query: str) -> str:
|
|
4141
|
-
"""Search
|
|
4012
|
+
"""Search for factual or encyclopedic information using free no-key sources.
|
|
4142
4013
|
|
|
4143
|
-
Use this INSTEAD of guessing a URL
|
|
4144
|
-
|
|
4014
|
+
Use this INSTEAD of guessing a URL when the user asks to 'find' something or asks a
|
|
4015
|
+
general-knowledge question. Returns a short summary and a source URL.
|
|
4145
4016
|
|
|
4146
4017
|
Args:
|
|
4147
|
-
query: What to look up, e.g. '
|
|
4018
|
+
query: What to look up, e.g. 'capital of France' or 'productivity day-to-day method'.
|
|
4148
4019
|
"""
|
|
4149
4020
|
dup, nudge = _dedup("web_search", query,
|
|
4150
4021
|
"Use the facts you have ALREADY gathered: if the task asks for a "
|
|
@@ -4157,13 +4028,9 @@ def run_agent_chat(cfg):
|
|
|
4157
4028
|
if blocked:
|
|
4158
4029
|
return blocked
|
|
4159
4030
|
_emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
|
|
4160
|
-
# SearXNG URL (task #103)
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
searxng_url=(cfg.get("searxngUrl") or "").strip(),
|
|
4164
|
-
search_base_url=(cfg.get("searchBaseURL") or "").strip(),
|
|
4165
|
-
search_model_id=(cfg.get("searchModelId") or "").strip(),
|
|
4166
|
-
)
|
|
4031
|
+
# SearXNG URL (task #103): from Settings (cfg.searxngUrl) if the API sends it, else
|
|
4032
|
+
# the GONEXT_SEARXNG_URL env fallback inside _web_search_impl. Keyless either way.
|
|
4033
|
+
result = _web_search_impl(query, searxng_url=(cfg.get("searxngUrl") or "").strip())
|
|
4167
4034
|
_log(f"web_search {query[:60]!r} → {result[:80]}")
|
|
4168
4035
|
_last_obs["text"] = result
|
|
4169
4036
|
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.280",
|
|
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",
|