@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.
@@ -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
@@ -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 _web_search_impl(query, max_results=5, searxng_url=""):
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 and return answer-ready condensed content so the model usually does
614
- NOT need a slow fetch_url round-trip. Model-free; never fabricates.
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
- ckey = (q.lower(), searxng)
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
- result = _web_search_impl(query, searxng_url=(cfg.get("searxngUrl") or "").strip())
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.283",
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",