@tiens.nguyen/gonext-local-worker 1.0.278 → 1.0.281

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.
@@ -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
@@ -413,15 +413,23 @@ def _norm_url(u):
413
413
 
414
414
 
415
415
  def _bk_ddg_html(q, timeout=6):
416
- """DuckDuckGo HTML endpoint → real web results [{title,snippet,url}] (keyless)."""
416
+ """DuckDuckGo HTML endpoint → real web results [{title,snippet,url}] (keyless).
417
+
418
+ Parses each result as a UNIT (task #104 fix): for every result__a anchor we take the
419
+ snippet that belongs to IT — the result__snippet appearing before the NEXT anchor —
420
+ instead of zipping two independent global lists. The old zip silently misaligned
421
+ snippets onto the wrong title/URL the moment any result lacked a snippet (ads, news,
422
+ or snippet-less rows), so the model read evidence attached to the wrong page.
423
+ """
417
424
  from urllib.parse import quote, unquote
418
425
  html_body = _get_text(f"https://html.duckduckgo.com/html/?q={quote(q)}", timeout=timeout)
419
426
  if not html_body:
420
427
  return []
421
428
  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):
429
+ anchors = list(re.finditer(
430
+ r'<a\b[^>]*class="[^"]*result__a[^"]*"[^>]*>.*?</a>', html_body, re.S))
431
+ for idx, am in enumerate(anchors):
432
+ a = am.group(0)
425
433
  hm = re.search(r'href="([^"]*)"', a)
426
434
  if not hm:
427
435
  continue
@@ -433,16 +441,25 @@ def _bk_ddg_html(q, timeout=6):
433
441
  if not real.startswith("http"):
434
442
  continue
435
443
  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})
444
+ if not title:
445
+ continue
446
+ # Snippet = the result__snippet that lies BETWEEN this anchor and the next one
447
+ # (i.e. inside this result's block). Missing → "" with no shift onto the next row.
448
+ seg_end = anchors[idx + 1].start() if idx + 1 < len(anchors) else len(html_body)
449
+ seg = html_body[am.end():seg_end]
450
+ sm = re.search(
451
+ r'<a\b[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>', seg, re.S)
452
+ snip = _search_strip(sm.group(1)) if sm else ""
453
+ out.append({"title": title[:120], "snippet": snip, "url": real})
439
454
  return out
440
455
 
441
456
 
442
457
  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)."""
458
+ """Self-hosted SearXNG JSON API → {results:[{title,snippet,url}], answers:[str]} (keyless).
459
+
460
+ 'answers' + 'infoboxes' are SearXNG's aggregated SERP answer box / knowledge panel a
461
+ DIRECT answer we can surface without reading any page (task #104). Returns the dict shape
462
+ so _web_search_impl can promote answers into the 'Answer:' block."""
446
463
  from urllib.parse import quote
447
464
  base = (base or "").strip().rstrip("/")
448
465
  if not base:
@@ -459,15 +476,16 @@ def _bk_searxng(q, base, timeout=6):
459
476
  "snippet": _search_strip(r.get("content") or ""),
460
477
  "url": url,
461
478
  })
479
+ # 'answers' entries are plain strings on some versions, {answer: ...} dicts on others.
462
480
  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 ""))
481
+ txt = _search_strip(str((a.get("answer") if isinstance(a, dict) else a) or ""))
464
482
  if txt:
465
- answers.append(txt[:600])
483
+ answers.append(txt)
466
484
  for ib in (data.get("infoboxes") or []):
467
485
  if isinstance(ib, dict):
468
- txt = _search_strip(ib.get("content") or "")
486
+ txt = _search_strip(str(ib.get("content") or ""))
469
487
  if txt:
470
- answers.append(txt[:600])
488
+ answers.append(txt)
471
489
  return {"results": results, "answers": answers}
472
490
 
473
491
 
@@ -508,91 +526,92 @@ def _bk_ddg_ia_summary(q, timeout=6):
508
526
  return ("", "")
509
527
 
510
528
 
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()
529
+ def _condense_for_query(text, query, budget=1500):
530
+ """Keep the query-relevant + STRUCTURAL lines of a page's text, bounded to `budget`
531
+ chars, in original order (task #104). Structural lines table rows (which _html_to_text
532
+ renders with ' | '), list items, or lines carrying a year / clock time are ALWAYS kept
533
+ even at zero prose-score, so schedules / fixtures / specs survive instead of being
534
+ summarized away (the flatten-to-summary failure). Model-free: pure text selection."""
535
+ if not text:
536
+ return ""
537
+ terms = {w for w in re.findall(r"\w+", (query or "").lower()) if len(w) > 2}
538
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
539
+ picked = []
540
+ for ln in lines:
541
+ low = ln.lower()
542
+ score = sum(1 for t in terms if t in low)
543
+ structural = (
544
+ "|" in ln
545
+ or bool(re.match(r"^([-*•]|\d+[.)])\s", ln))
546
+ or bool(re.search(r"\b(19|20)\d{2}\b", ln))
547
+ or bool(re.search(r"\d{1,2}:\d{2}", ln))
548
+ )
549
+ if score > 0 or structural:
550
+ picked.append(ln)
551
+ if not picked: # nothing matched — fall back to the page lead so we return SOMETHING
552
+ picked = lines[:20]
553
+ kept, total = [], 0
554
+ for ln in picked:
555
+ if total + len(ln) + 1 > budget:
556
+ continue
557
+ kept.append(ln)
558
+ total += len(ln) + 1
559
+ return "\n".join(kept)
533
560
 
534
561
 
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
562
+ def _read_pages(ranked, query, per_page_chars=1500, per_timeout=5, overall=6):
563
+ """Read the given result pages IN PARALLEL {url: condensed_text} (task #104).
564
+
565
+ Model-free: HTTP GET (_fetch_page_impl) + _html_to_text + _condense_for_query. Each URL
566
+ is SSRF-guarded (_rag_assert_safe_url) BEFORE fetching — these come from an untrusted
567
+ SERP, and this reads them automatically (not a URL the model explicitly chose), so an
568
+ entry pointing at an internal/link-local host must never be fetched. PDFs/binaries and
569
+ any per-page failure are skipped (the caller falls back to that result's snippet)."""
570
+ from concurrent.futures import ThreadPoolExecutor, as_completed
540
571
  import time as _t
572
+ if not ranked:
573
+ return {}
541
574
 
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)
575
+ def _read_one(url):
576
+ try:
577
+ _rag_assert_safe_url(url)
578
+ except Exception: # noqa: BLE001 — SSRF/scheme refusal → skip this page
579
+ return url, ""
580
+ status, ctype, raw = _fetch_page_impl(url, timeout=per_timeout, max_bytes=300000)
581
+ if status is None or (isinstance(status, int) and status >= 400):
582
+ return url, ""
583
+ if "application/pdf" in ctype or raw[:5].lstrip().startswith(b"%PDF"):
584
+ return url, "" # binary — snippet fallback (fetch_url can't read PDFs either)
585
+ text = _html_to_text(raw.decode("utf-8", errors="replace"), limit=per_page_chars * 3)
586
+ return url, _condense_for_query(text, query, per_page_chars)
550
587
 
551
588
  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):
589
+ # Manual shutdown (not `with`): a slow page must not hold the whole search past `overall`
590
+ # via shutdown(wait=True). We collect what finishes within the deadline, then return.
591
+ ex = ThreadPoolExecutor(max_workers=len(ranked))
592
+ futs = {ex.submit(_read_one, e["url"]): e["url"] for e in ranked}
593
+ end = _t.time() + overall
594
+ try:
595
+ for fut in as_completed(futs, timeout=overall):
558
596
  try:
559
- uu, txt = fut.result(timeout=max(0.0, end - _t.time()))
560
- if txt:
561
- out[uu] = txt
597
+ url, txt = fut.result(timeout=max(0.0, end - _t.time()))
562
598
  except Exception: # noqa: BLE001
563
599
  continue
600
+ if txt:
601
+ out[url] = txt
602
+ except Exception: # noqa: BLE001 — overall deadline hit; return what finished
603
+ pass
604
+ finally:
605
+ ex.shutdown(wait=False, cancel_futures=True)
564
606
  return out
565
607
 
566
608
 
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.
609
+ def _web_search_impl(query, max_results=5, searxng_url=""):
610
+ """Keyless web search that READS for the model (tasks #103 + #104): run DuckDuckGo-HTML
611
+ + SearXNG (if configured) + Wikipedia CONCURRENTLY, merge/dedupe/rank by cross-engine
612
+ agreement, surface any SERP answer box, then (unless GONEXT_SEARCH_READ=0) READ the top
613
+ pages in parallel and return answer-ready condensed content — so the model usually does
614
+ NOT need a slow fetch_url round-trip. Model-free; never fabricates.
596
615
  """
597
616
  import os
598
617
  import time as _t
@@ -603,29 +622,38 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
603
622
  return "web_search: empty query."
604
623
  searxng = (searxng_url or os.environ.get("GONEXT_SEARXNG_URL") or "").strip()
605
624
 
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)
625
+ # Short TTL cache (identical query within a turn).
626
+ ckey = (q.lower(), searxng)
609
627
  hit = _WEB_SEARCH_CACHE.get(ckey)
610
628
  if hit and (_t.time() - hit[0]) < _WEB_SEARCH_TTL:
611
629
  return hit[1]
612
630
 
613
631
  per_timeout = 6 # per-backend socket timeout
614
- overall = 9 # overall wall-clock deadline across all backends
632
+ overall = 5 # overall deadline; a straggler is abandoned (shutdown is non-blocking)
615
633
 
616
634
  # Result-producing backends (run in parallel). Order = tie-break priority in the merge.
617
635
  tasks = [("ddg", lambda: _bk_ddg_html(q, per_timeout))]
618
636
  if searxng:
637
+ # SearXNG aggregates Wikipedia (and everything else) server-side, so the standalone
638
+ # Wikipedia backend is redundant here — and its full-text API is the slow straggler
639
+ # (~7s) that otherwise dominates the whole search. Only pay for it as a keyless
640
+ # fallback when SearXNG isn't configured.
619
641
  tasks.append(("searxng", lambda: _bk_searxng(q, searxng, per_timeout)))
620
- tasks.append(("wikipedia", lambda: _bk_wikipedia(q, per_timeout, max_results)))
642
+ else:
643
+ tasks.append(("wikipedia", lambda: _bk_wikipedia(q, per_timeout, max_results)))
621
644
  tasks.append(("summary", lambda: _bk_ddg_ia_summary(q, per_timeout)))
622
645
 
623
646
  results_by = {}
624
- answers = [] # zero-click 'answer box' text (SearXNG + DDG-IA)
647
+ answers = [] # #104: direct answer-box text (SearXNG answers/infoboxes)
625
648
  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
649
+ # NOT a `with` block: the context manager's shutdown(wait=True) would block on straggler
650
+ # threads PAST the deadline (a slow SearXNG/engine could add 10s+). We read results up to
651
+ # `overall`, then shutdown(wait=False) so the function returns on time; leftover backend
652
+ # threads finish harmlessly in the background.
653
+ ex = ThreadPoolExecutor(max_workers=len(tasks))
654
+ futs = {ex.submit(fn): name for name, fn in tasks}
655
+ end = _t.time() + overall
656
+ try:
629
657
  for fut, name in futs.items():
630
658
  try:
631
659
  r = fut.result(timeout=max(0.0, end - _t.time()))
@@ -633,13 +661,13 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
633
661
  continue
634
662
  if name == "summary":
635
663
  summary, summary_src = r
636
- elif name == "searxng":
637
- results_by["searxng"] = r.get("results", [])
664
+ elif name == "searxng": # dict shape: {results, answers}
665
+ results_by[name] = r.get("results", [])
638
666
  answers.extend(r.get("answers", []))
639
667
  else:
640
668
  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 ""))
669
+ finally:
670
+ ex.shutdown(wait=False, cancel_futures=True)
643
671
 
644
672
  # Merge + dedupe by normalized URL; rank by cross-engine AGREEMENT then first-seen order.
645
673
  merged = {}
@@ -661,55 +689,45 @@ def _web_search_impl(query, max_results=5, searxng_url="", search_base_url="", s
661
689
  order += 1
662
690
  ranked = sorted(merged.values(), key=lambda e: (-e["hits"], e["order"]))[:max_results]
663
691
 
664
- if not answers and not ranked:
692
+ if not summary and not ranked and not answers:
665
693
  return (
666
694
  f"No results found for '{q}'. Tell the user you couldn't find this — "
667
695
  "do NOT invent an answer or a URL."
668
696
  )
669
697
 
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)
698
+ # #104 read step: unless disabled, READ the top pages inline so the model gets
699
+ # answer-ready content in ONE call (no slow fetch_url round-trips). GONEXT_SEARCH_READ=0
700
+ # is the kill switch link+snippet behaviour of #103.
701
+ def _env_int(name, default):
702
+ try:
703
+ return max(1, int(os.environ.get(name, "").strip()))
704
+ except (ValueError, TypeError):
705
+ return default
706
+ read_on = os.environ.get("GONEXT_SEARCH_READ", "1").strip().lower() not in ("0", "false", "no")
707
+ read_k = _env_int("GONEXT_SEARCH_READ_K", 3)
708
+ page_chars = _env_int("GONEXT_SEARCH_PAGE_CHARS", 1500)
709
+ page_texts = _read_pages(ranked[:read_k], q, page_chars) if (read_on and ranked) else {}
686
710
 
687
711
  parts = []
688
- if answers:
689
- parts.append("Answer:\n" + "\n".join(f"• {a}" for a in answers[:3]))
712
+ # Answer box(es) first — a direct answer often needs no page read at all.
713
+ answer_lines = [f"• {a[:600]}" for a in answers[:3]]
714
+ if summary:
715
+ answer_lines.append(f"• {summary[:600]}" + (f" (source: {summary_src})" if summary_src else ""))
716
+ if answer_lines:
717
+ parts.append("Answer:\n" + "\n".join(answer_lines))
718
+
690
719
  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]
720
+ if page_texts:
721
+ lines = ["Sources already READ for you (you usually do NOT need fetch_url; "
722
+ "call it only to read a specific page in full):"]
723
+ else:
724
+ lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
694
725
  for i, e in enumerate(ranked, 1):
695
726
  head = f"[{i}] {e['title']}\n {e['url']}"
696
727
  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))
728
+ lines.append(head + (f"\n{body}" if body else ""))
729
+ parts.append("\n".join(lines))
699
730
  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
731
  _WEB_SEARCH_CACHE[ckey] = (_t.time(), out)
714
732
  return out
715
733
 
@@ -3073,15 +3091,6 @@ def run_agent_chat(cfg):
3073
3091
  f"start model={agent_model_id!r} base={agent_base_url!r} "
3074
3092
  f"codeModel={coding_model_id!r} codeBase={coding_base_url!r} maxSteps={max_steps}"
3075
3093
  )
3076
- # Search model (task #105): who synthesizes web_search answers. Logged so a run shows at a
3077
- # glance whether it's wired.
3078
- _search_base = (cfg.get("searchBaseURL") or "").strip()
3079
- _search_model = (cfg.get("searchModelId") or "").strip()
3080
- _log(
3081
- f"search model: {_search_model!r} @ {_search_base}"
3082
- if _search_base
3083
- else "search model: none (web_search returns read page content, not a synthesized answer)"
3084
- )
3085
3094
 
3086
3095
  # Build the task from the conversation history. We include the FULL conversation
3087
3096
  # (both user AND assistant turns) so the agent remembers what it already did —
@@ -4147,13 +4156,16 @@ def run_agent_chat(cfg):
4147
4156
 
4148
4157
  @tool
4149
4158
  def web_search(query: str) -> str:
4150
- """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.
4159
+ """Search the web AND read the top pages for you, in one call.
4151
4160
 
4152
- Use this INSTEAD of guessing a URL whenever the user asks to 'find' something or asks a
4153
- factual / current-events question.
4161
+ Use this INSTEAD of guessing a URL when the user asks to 'find' something or asks a
4162
+ general-knowledge question. Returns any direct answer box plus the top sources with
4163
+ their CONTENT ALREADY READ (HTML stripped, tables kept) — so you can usually answer
4164
+ or call final_answer/create_pdf straight away WITHOUT calling fetch_url again. Only
4165
+ call fetch_url when you specifically need a single page in full.
4154
4166
 
4155
4167
  Args:
4156
- query: What to look up, e.g. 'FIFA World Cup 2026 final date' or 'express error handling best practices'.
4168
+ query: What to look up, e.g. 'capital of France' or 'world cup 2026 schedule'.
4157
4169
  """
4158
4170
  dup, nudge = _dedup("web_search", query,
4159
4171
  "Use the facts you have ALREADY gathered: if the task asks for a "
@@ -4166,13 +4178,9 @@ def run_agent_chat(cfg):
4166
4178
  if blocked:
4167
4179
  return blocked
4168
4180
  _emit({"type": "step", "text": f"Searching the web → {query[:80]}"})
4169
- # SearXNG URL (task #103) + optional search model (task #105) from Settings.
4170
- result = _web_search_impl(
4171
- query,
4172
- searxng_url=(cfg.get("searxngUrl") or "").strip(),
4173
- search_base_url=(cfg.get("searchBaseURL") or "").strip(),
4174
- search_model_id=(cfg.get("searchModelId") or "").strip(),
4175
- )
4181
+ # SearXNG URL (task #103): from Settings (cfg.searxngUrl) if the API sends it, else
4182
+ # the GONEXT_SEARXNG_URL env fallback inside _web_search_impl. Keyless either way.
4183
+ result = _web_search_impl(query, searxng_url=(cfg.get("searxngUrl") or "").strip())
4176
4184
  _log(f"web_search {query[:60]!r} → {result[:80]}")
4177
4185
  _last_obs["text"] = result
4178
4186
  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.278",
3
+ "version": "1.0.281",
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",