@tiens.nguyen/gonext-local-worker 1.0.274 → 1.0.276
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 +3 -0
- package/gonext_agent_chat.py +339 -69
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -1893,6 +1893,9 @@ 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 ?? "",
|
|
1896
1899
|
tools: payload?.tools ?? ["http_request"],
|
|
1897
1900
|
// 0 = "not explicitly set": python defaults to 5, but may auto-raise to 12 when
|
|
1898
1901
|
// workspaces are registered. Passing a literal 5 here would look user-set and
|
package/gonext_agent_chat.py
CHANGED
|
@@ -364,90 +364,354 @@ def _get_json(url, timeout=15):
|
|
|
364
364
|
return None
|
|
365
365
|
|
|
366
366
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
367
|
+
# --- web_search: parallel keyless multi-backend (task #103) ------------------------------
|
|
368
|
+
# The old impl called DuckDuckGo's INSTANT-ANSWER API + Wikipedia sequentially. The IA API
|
|
369
|
+
# isn't a real web SERP (only curated abstracts for known entities), so general queries came
|
|
370
|
+
# back thin/empty and slow (2-3 sequential 15-25s fetches). This runs several KEYLESS
|
|
371
|
+
# backends CONCURRENTLY with short per-backend timeouts and an overall deadline, then merges
|
|
372
|
+
# + dedupes + ranks by cross-engine agreement. Backends: DuckDuckGo HTML (real results),
|
|
373
|
+
# a self-hosted SearXNG metasearch (aggregates Google/Bing/Yahoo/… server-side, keyless — set
|
|
374
|
+
# GONEXT_SEARXNG_URL or cfg.searxngUrl), and Wikipedia (encyclopedic). No API keys.
|
|
375
|
+
_WEB_SEARCH_TTL = 300 # seconds — short cache so repeated queries in a turn don't re-hit net
|
|
376
|
+
_WEB_SEARCH_CACHE: dict = {} # (query_lower, searxng_url) -> (ts, result_text)
|
|
377
|
+
_WEB_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
378
|
+
"(KHTML, like Gecko) Chrome/121.0 Safari/537.36")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _get_text(url, timeout=6, ua=None, headers=None):
|
|
382
|
+
"""GET a URL and return the decoded body text ("" on failure). For scraping SERP HTML."""
|
|
383
|
+
h = {"User-Agent": ua or _WEB_UA,
|
|
384
|
+
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
|
385
|
+
"Accept-Language": "en-US,en;q=0.9"}
|
|
386
|
+
if headers:
|
|
387
|
+
h.update(headers)
|
|
388
|
+
req = urllib.request.Request(url, method="GET", headers=h)
|
|
389
|
+
try:
|
|
390
|
+
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
|
391
|
+
return resp.read(600_000).decode("utf-8", errors="replace")
|
|
392
|
+
except Exception as e: # noqa: BLE001
|
|
393
|
+
_log(f"web_search GET failed {url[:80]}: {e}")
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _search_strip(s):
|
|
377
398
|
import html as _html
|
|
378
|
-
|
|
379
|
-
q = (query or "").strip()
|
|
380
|
-
if not q:
|
|
381
|
-
return "web_search: empty query."
|
|
399
|
+
return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
|
|
382
400
|
|
|
383
|
-
def _strip(s):
|
|
384
|
-
return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
|
|
385
401
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
402
|
+
def _norm_url(u):
|
|
403
|
+
"""Normalize a URL for dedupe (lower host/scheme, drop fragment + trailing slash)."""
|
|
404
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
405
|
+
u = (u or "").strip()
|
|
406
|
+
if not u:
|
|
407
|
+
return ""
|
|
408
|
+
try:
|
|
409
|
+
s = urlsplit(u)
|
|
410
|
+
return urlunsplit((s.scheme.lower(), s.netloc.lower(), s.path.rstrip("/"), "", ""))
|
|
411
|
+
except Exception: # noqa: BLE001
|
|
412
|
+
return u.rstrip("/")
|
|
390
413
|
|
|
391
|
-
def _add(title, snippet, url):
|
|
392
|
-
url = (url or "").strip()
|
|
393
|
-
if not url or url in seen or len(results) >= max_results:
|
|
394
|
-
return
|
|
395
|
-
seen.add(url)
|
|
396
|
-
results.append((_strip(title) or url, _strip(snippet), url))
|
|
397
414
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
)
|
|
402
|
-
if
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
415
|
+
def _bk_ddg_html(q, timeout=6):
|
|
416
|
+
"""DuckDuckGo HTML endpoint → real web results [{title,snippet,url}] (keyless)."""
|
|
417
|
+
from urllib.parse import quote, unquote
|
|
418
|
+
html_body = _get_text(f"https://html.duckduckgo.com/html/?q={quote(q)}", timeout=timeout)
|
|
419
|
+
if not html_body:
|
|
420
|
+
return []
|
|
421
|
+
out = []
|
|
422
|
+
anchors = re.findall(r'<a\b[^>]*class="result__a"[^>]*>.*?</a>', html_body, re.S)
|
|
423
|
+
snippets = re.findall(r'<a\b[^>]*class="result__snippet"[^>]*>(.*?)</a>', html_body, re.S)
|
|
424
|
+
for i, a in enumerate(anchors):
|
|
425
|
+
hm = re.search(r'href="([^"]*)"', a)
|
|
426
|
+
if not hm:
|
|
427
|
+
continue
|
|
428
|
+
href = hm.group(1)
|
|
429
|
+
um = re.search(r'[?&]uddg=([^&]+)', href)
|
|
430
|
+
real = unquote(um.group(1)) if um else href
|
|
431
|
+
if real.startswith("//"):
|
|
432
|
+
real = "https:" + real
|
|
433
|
+
if not real.startswith("http"):
|
|
434
|
+
continue
|
|
435
|
+
title = _search_strip(a)
|
|
436
|
+
snip = _search_strip(snippets[i]) if i < len(snippets) else ""
|
|
437
|
+
if title:
|
|
438
|
+
out.append({"title": title[:120], "snippet": snip, "url": real})
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _bk_searxng(q, base, timeout=6):
|
|
443
|
+
"""Self-hosted SearXNG JSON API (keyless) → {"results":[{title,snippet,url}], "answers":[str]}.
|
|
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)."""
|
|
446
|
+
from urllib.parse import quote
|
|
447
|
+
base = (base or "").strip().rstrip("/")
|
|
448
|
+
if not base:
|
|
449
|
+
return {"results": [], "answers": []}
|
|
450
|
+
data = _get_json(f"{base}/search?q={quote(q)}&format=json&safesearch=0", timeout=timeout)
|
|
451
|
+
results, answers = [], []
|
|
452
|
+
if isinstance(data, dict):
|
|
453
|
+
for r in (data.get("results") or []):
|
|
454
|
+
url = (r.get("url") or "").strip()
|
|
455
|
+
if not url:
|
|
456
|
+
continue
|
|
457
|
+
results.append({
|
|
458
|
+
"title": _search_strip(r.get("title") or url)[:120],
|
|
459
|
+
"snippet": _search_strip(r.get("content") or ""),
|
|
460
|
+
"url": url,
|
|
461
|
+
})
|
|
462
|
+
for a in (data.get("answers") or []):
|
|
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}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _bk_wikipedia(q, timeout=6, n=5):
|
|
475
|
+
"""Wikipedia full-text search → encyclopedic candidate pages [{title,snippet,url}]."""
|
|
476
|
+
from urllib.parse import quote
|
|
413
477
|
search = _get_json(
|
|
414
478
|
"https://en.wikipedia.org/w/api.php?action=query&list=search"
|
|
415
|
-
f"&srsearch={quote(q)}&srlimit={
|
|
479
|
+
f"&srsearch={quote(q)}&srlimit={n}&format=json",
|
|
480
|
+
timeout=timeout,
|
|
416
481
|
)
|
|
482
|
+
out = []
|
|
417
483
|
try:
|
|
418
484
|
hits = search["query"]["search"]
|
|
419
485
|
except Exception: # noqa: BLE001
|
|
420
486
|
hits = []
|
|
421
487
|
for hit in hits:
|
|
422
488
|
slug = quote(hit.get("title", "").replace(" ", "_"))
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
489
|
+
out.append({
|
|
490
|
+
"title": hit.get("title", ""),
|
|
491
|
+
"snippet": _search_strip(hit.get("snippet", "")),
|
|
492
|
+
"url": f"https://en.wikipedia.org/wiki/{slug}",
|
|
493
|
+
})
|
|
494
|
+
return out
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _bk_ddg_ia_summary(q, timeout=6):
|
|
498
|
+
"""DuckDuckGo Instant Answer — a direct abstract for a well-known entity (summary only)."""
|
|
499
|
+
from urllib.parse import quote
|
|
500
|
+
ddg = _get_json(
|
|
501
|
+
f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1",
|
|
502
|
+
timeout=timeout,
|
|
503
|
+
)
|
|
504
|
+
if isinstance(ddg, dict):
|
|
505
|
+
abstract = (ddg.get("AbstractText") or "").strip()
|
|
506
|
+
if abstract:
|
|
507
|
+
return (abstract[:1200], (ddg.get("AbstractURL") or "").strip())
|
|
508
|
+
return ("", "")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _condense_for_query(text, query, max_chars=1600):
|
|
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=""):
|
|
592
|
+
"""Parallel keyless web search (task #103): run DuckDuckGo-HTML + SearXNG (if configured)
|
|
593
|
+
+ Wikipedia CONCURRENTLY with a short deadline, merge/dedupe/rank by cross-engine
|
|
594
|
+
agreement, and return a short SUMMARY (when available) + a numbered list of the top
|
|
595
|
+
PAGES (title — snippet — URL) so the model can fetch_url() the best one. Never fabricates.
|
|
596
|
+
"""
|
|
597
|
+
import os
|
|
598
|
+
import time as _t
|
|
599
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
600
|
+
|
|
601
|
+
q = (query or "").strip()
|
|
602
|
+
if not q:
|
|
603
|
+
return "web_search: empty query."
|
|
604
|
+
searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
|
|
605
|
+
|
|
606
|
+
# Short TTL cache (identical query within a turn). Key includes the search model so a
|
|
607
|
+
# synthesized answer isn't served when the model config changed.
|
|
608
|
+
ckey = (q.lower(), searxng, search_base_url, search_model_id)
|
|
609
|
+
hit = _WEB_SEARCH_CACHE.get(ckey)
|
|
610
|
+
if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
|
|
611
|
+
return hit[1]
|
|
612
|
+
|
|
613
|
+
per_timeout = 6 # per-backend socket timeout
|
|
614
|
+
overall = 9 # overall wall-clock deadline across all backends
|
|
615
|
+
|
|
616
|
+
# Result-producing backends (run in parallel). Order = tie-break priority in the merge.
|
|
617
|
+
tasks = [("ddg", lambda: _bk_ddg_html(q, per_timeout))]
|
|
618
|
+
if searxng:
|
|
619
|
+
tasks.append(("searxng", lambda: _bk_searxng(q, searxng, per_timeout)))
|
|
620
|
+
tasks.append(("wikipedia", lambda: _bk_wikipedia(q, per_timeout, max_results)))
|
|
621
|
+
tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
|
|
622
|
+
|
|
623
|
+
results_by = {}
|
|
624
|
+
answers = [] # zero-click 'answer box' text (SearXNG + DDG-IA)
|
|
625
|
+
summary, summary_src = "", ""
|
|
626
|
+
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
|
|
627
|
+
futs = {ex.submit(fn): name for name, fn in tasks}
|
|
628
|
+
end = _t.time() + overall
|
|
629
|
+
for fut, name in futs.items():
|
|
630
|
+
try:
|
|
631
|
+
r = fut.result(timeout=max(0.0, end - _t.time()))
|
|
632
|
+
except Exception: # noqa: BLE001 — a slow/failed backend is skipped
|
|
633
|
+
continue
|
|
634
|
+
if name == "summary":
|
|
635
|
+
summary, summary_src = r
|
|
636
|
+
elif name == "searxng":
|
|
637
|
+
results_by["searxng"] = r.get("results", [])
|
|
638
|
+
answers.extend(r.get("answers", []))
|
|
639
|
+
else:
|
|
640
|
+
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
|
+
|
|
644
|
+
# Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
|
|
645
|
+
merged = {}
|
|
646
|
+
order = 0
|
|
647
|
+
for name in ("searxng", "ddg", "wikipedia"):
|
|
648
|
+
for r in results_by.get(name, []):
|
|
649
|
+
nu = _norm_url(r.get("url"))
|
|
650
|
+
if not nu:
|
|
651
|
+
continue
|
|
652
|
+
if nu in merged:
|
|
653
|
+
merged[nu]["hits"] += 1
|
|
654
|
+
if not merged[nu]["snippet"] and r.get("snippet"):
|
|
655
|
+
merged[nu]["snippet"] = r["snippet"]
|
|
656
|
+
else:
|
|
657
|
+
merged[nu] = {
|
|
658
|
+
"title": r.get("title") or r["url"], "snippet": r.get("snippet", ""),
|
|
659
|
+
"url": r["url"], "hits": 1, "order": order,
|
|
660
|
+
}
|
|
661
|
+
order += 1
|
|
662
|
+
ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
|
|
663
|
+
|
|
664
|
+
if not answers and not ranked:
|
|
434
665
|
return (
|
|
435
666
|
f"No results found for '{q}'. Tell the user you couldn't find this — "
|
|
436
667
|
"do NOT invent an answer or a URL."
|
|
437
668
|
)
|
|
438
669
|
|
|
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
|
+
|
|
439
687
|
parts = []
|
|
440
|
-
if
|
|
441
|
-
parts.append(
|
|
442
|
-
if
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
688
|
+
if answers:
|
|
689
|
+
parts.append("Answer:\n" + "\n".join(f"• {a}" for a in answers[:3]))
|
|
690
|
+
if ranked:
|
|
691
|
+
header = ("Sources — already READ for you (you usually do NOT need fetch_url; "
|
|
692
|
+
"call it only to read a specific page in full):")
|
|
693
|
+
blocks = [header]
|
|
694
|
+
for i, e in enumerate(ranked, 1):
|
|
695
|
+
head = f"[{i}] {e['title']}\n {e['url']}"
|
|
696
|
+
body = page_texts.get(e["url"]) or (e["snippet"][:200] if e["snippet"] else "")
|
|
697
|
+
blocks.append(head + (f"\n{body}" if body else ""))
|
|
698
|
+
parts.append("\n\n".join(blocks))
|
|
699
|
+
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
|
+
_WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
|
|
714
|
+
return out
|
|
451
715
|
|
|
452
716
|
|
|
453
717
|
class _AgentConfigError(RuntimeError):
|
|
@@ -3874,13 +4138,13 @@ def run_agent_chat(cfg):
|
|
|
3874
4138
|
|
|
3875
4139
|
@tool
|
|
3876
4140
|
def web_search(query: str) -> str:
|
|
3877
|
-
"""Search for
|
|
4141
|
+
"""Search the web AND read the top results in one call — returns an 'Answer:' (when found) plus the READ CONTENT of the best pages, not just links. Because the pages are already read for you, you normally do NOT need to call fetch_url afterwards — answer or create_pdf directly from what this returns. Only call fetch_url when you must read one specific page in full.
|
|
3878
4142
|
|
|
3879
|
-
Use this INSTEAD of guessing a URL
|
|
3880
|
-
|
|
4143
|
+
Use this INSTEAD of guessing a URL whenever the user asks to 'find' something or asks a
|
|
4144
|
+
factual / current-events question.
|
|
3881
4145
|
|
|
3882
4146
|
Args:
|
|
3883
|
-
query: What to look up, e.g. '
|
|
4147
|
+
query: What to look up, e.g. 'FIFA World Cup 2026 final date' or 'express error handling best practices'.
|
|
3884
4148
|
"""
|
|
3885
4149
|
dup, nudge = _dedup("web_search", query,
|
|
3886
4150
|
"Use the facts you have ALREADY gathered: if the task asks for a "
|
|
@@ -3893,7 +4157,13 @@ def run_agent_chat(cfg):
|
|
|
3893
4157
|
if blocked:
|
|
3894
4158
|
return blocked
|
|
3895
4159
|
_emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
|
|
3896
|
-
|
|
4160
|
+
# SearXNG URL (task #103) + optional search model (task #105) from Settings.
|
|
4161
|
+
result = _web_search_impl(
|
|
4162
|
+
query,
|
|
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
|
+
)
|
|
3897
4167
|
_log(f"web_search {query[:60]!r} → {result[:80]}")
|
|
3898
4168
|
_last_obs["text"] = result
|
|
3899
4169
|
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.276",
|
|
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",
|