@tiens.nguyen/gonext-local-worker 1.0.237 → 1.0.239

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.
Files changed (2) hide show
  1. package/gonext_agent_chat.py +169 -37
  2. package/package.json +1 -1
@@ -131,6 +131,12 @@ def _html_to_text(html_text, limit=3000):
131
131
  # Main menu / Donate / Create account…" before any article text, which both starves
132
132
  # the model of real content and bloats the per-step context (OOM risk on local MLX).
133
133
  text = re.sub(r"(?is)<(nav|header|footer|aside|form|button|menu)\b.*?</\1>", " ", text)
134
+ # Preserve TABLE structure before stripping tags: a cell boundary becomes " | "
135
+ # and a row becomes a newline, so a schedule/fixtures table survives as readable
136
+ # pipe-delimited rows instead of collapsing into a wall of words — a "make a table"
137
+ # task (task #75) is unanswerable if the source table is flattened on the way in.
138
+ text = re.sub(r"(?i)</(td|th)>", " | ", text)
139
+ text = re.sub(r"(?i)</tr>", "\n", text)
134
140
  # Turn block-ending tags into newlines so document structure survives stripping.
135
141
  text = re.sub(r"(?i)<(br|/p|/div|/li|/tr|/h[1-6]|/section|/article)\s*>", "\n", text)
136
142
  # Remove all remaining tags.
@@ -358,60 +364,90 @@ def _get_json(url, timeout=15):
358
364
  return None
359
365
 
360
366
 
361
- def _web_search_impl(query):
367
+ def _web_search_impl(query, max_results=5):
362
368
  """Look up factual info via free no-key JSON APIs (DuckDuckGo + Wikipedia).
363
369
 
364
- Returns a short text summary with a source URL, or a 'no results' message.
365
- Tries DuckDuckGo Instant Answer first, then falls back to a Wikipedia search +
366
- REST summary. Never fabricates callers should surface 'no results' honestly.
370
+ Returns a short SUMMARY (for a direct-fact question) followed by a numbered list
371
+ of the top matching PAGES (title snippet URL), so the model can fetch_url() a
372
+ real page when a one-line summary can't carry the data it needs (a schedule, a
373
+ table, a fixtures list). Returning only a single Wikipedia intro used to trap weak
374
+ models in a re-search loop — they never saw a candidate URL to open (task #75).
375
+ Never fabricates — returns an honest 'no results' when nothing is found.
367
376
  """
377
+ import html as _html
368
378
  from urllib.parse import quote
369
379
  q = (query or "").strip()
370
380
  if not q:
371
381
  return "web_search: empty query."
372
382
 
373
- # 1) DuckDuckGo Instant Answer API.
383
+ def _strip(s):
384
+ return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
385
+
386
+ summary = ""
387
+ summary_src = ""
388
+ results = [] # (title, snippet, url)
389
+ seen = set()
390
+
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
+
398
+ # 1) DuckDuckGo Instant Answer — a direct abstract + related topics (as pages).
374
399
  ddg = _get_json(
375
400
  f"https://api.duckduckgo.com/?q={quote(q)}&format=json&no_html=1&skip_disambig=1"
376
401
  )
377
402
  if isinstance(ddg, dict):
378
403
  abstract = (ddg.get("AbstractText") or "").strip()
379
404
  if abstract:
380
- src = (ddg.get("AbstractURL") or "").strip()
381
- return f"{abstract[:1500]}\nSource: {src}" if src else abstract[:1500]
382
- # No abstract — use the first related topic that has text.
405
+ summary = abstract[:1200]
406
+ summary_src = (ddg.get("AbstractURL") or "").strip()
383
407
  for topic in ddg.get("RelatedTopics") or []:
384
- if isinstance(topic, dict) and topic.get("Text"):
385
- src = (topic.get("FirstURL") or "").strip()
386
- text = topic["Text"][:1500]
387
- return f"{text}\nSource: {src}" if src else text
408
+ if isinstance(topic, dict) and topic.get("Text") and topic.get("FirstURL"):
409
+ _add(topic["Text"][:80], topic["Text"], topic["FirstURL"])
388
410
 
389
- # 2) Wikipedia: find the best-matching title, then fetch its summary extract.
411
+ # 2) Wikipedia full-text search SEVERAL candidate pages with snippets, so the
412
+ # model can pick the specific one (e.g. a "…knockout stage" fixtures page).
390
413
  search = _get_json(
391
414
  "https://en.wikipedia.org/w/api.php?action=query&list=search"
392
- f"&srsearch={quote(q)}&format=json&srlimit=1"
415
+ f"&srsearch={quote(q)}&srlimit={max_results}&format=json"
393
416
  )
394
- title = ""
395
417
  try:
396
- title = search["query"]["search"][0]["title"]
418
+ hits = search["query"]["search"]
397
419
  except Exception: # noqa: BLE001
398
- title = ""
399
- if title:
400
- slug = quote(title.replace(" ", "_"))
401
- summary = _get_json("https://en.wikipedia.org/api/rest_v1/page/summary/" + slug)
402
- if isinstance(summary, dict):
403
- extract = (summary.get("extract") or "").strip()
404
- if extract:
405
- src = (
406
- (summary.get("content_urls") or {}).get("desktop", {}).get("page", "")
407
- or f"https://en.wikipedia.org/wiki/{slug}"
408
- )
409
- return f"{extract[:1500]}\nSource: {src}"
420
+ hits = []
421
+ for hit in hits:
422
+ slug = quote(hit.get("title", "").replace(" ", "_"))
423
+ _add(hit.get("title", ""), hit.get("snippet", ""),
424
+ f"https://en.wikipedia.org/wiki/{slug}")
425
+ # If no direct abstract, use the best-matching page's summary as the answer.
426
+ if not summary and hits:
427
+ slug = quote(hits[0]["title"].replace(" ", "_"))
428
+ s = _get_json("https://en.wikipedia.org/api/rest_v1/page/summary/" + slug)
429
+ if isinstance(s, dict):
430
+ summary = (s.get("extract") or "").strip()[:1200]
431
+ summary_src = f"https://en.wikipedia.org/wiki/{slug}"
432
+
433
+ if not summary and not results:
434
+ return (
435
+ f"No results found for '{q}'. Tell the user you couldn't find this — "
436
+ "do NOT invent an answer or a URL."
437
+ )
410
438
 
411
- return (
412
- f"No results found for '{q}'. Tell the user you couldn't find this — "
413
- "do NOT invent an answer or a URL."
414
- )
439
+ parts = []
440
+ if summary:
441
+ parts.append(summary + (f"\nSource: {summary_src}" if summary_src else ""))
442
+ if results:
443
+ lines = ["Top pages (call fetch_url on the most relevant to read it in full):"]
444
+ for i, (title, snippet, url) in enumerate(results, 1):
445
+ line = f"{i}. {title}"
446
+ if snippet:
447
+ line += f" — {snippet[:160]}"
448
+ lines.append(line + f"\n {url}")
449
+ parts.append("\n".join(lines))
450
+ return "\n\n".join(parts)
415
451
 
416
452
 
417
453
  class _AgentConfigError(RuntimeError):
@@ -1392,8 +1428,14 @@ def _format_text_for_pdf(text: str, title: str, base_url: str, api_key: str,
1392
1428
  fallback = f"# {title}\n\n{fallback}"
1393
1429
  try:
1394
1430
  from openai import OpenAI
1431
+ # timeout=20, NOT 60 (task #74): this formatting pass runs INSIDE a create_pdf
1432
+ # tool call, which itself runs under the agent's python-executor wall clock. A
1433
+ # 60s wait here could eat the whole step budget — the PDF then rendered+uploaded
1434
+ # fine but the model was told the step timed out, so it re-created the PDF from
1435
+ # scratch (3 duplicate PDFs, ~10 wasted minutes). Formatting is optional polish:
1436
+ # give it 20s, then fall back to the raw text and leave room for render+upload.
1395
1437
  client = OpenAI(base_url=base_url, api_key=api_key or "local",
1396
- max_retries=0, timeout=60)
1438
+ max_retries=0, timeout=20)
1397
1439
  system_content = (
1398
1440
  "You format raw text/data into clean Markdown for a PDF document. "
1399
1441
  "Add a single top-level '# Title', sensible headings, bullet lists, "
@@ -2961,8 +3003,15 @@ def run_agent_chat(cfg):
2961
3003
  )
2962
3004
 
2963
3005
  _emit({"type": "step", "text": "Formatting document…"})
3006
+ # CHAT model, not the coder (task #74 review): formatting raw text into
3007
+ # Markdown is a language task, and the chat model (local MLX) answers in
3008
+ # seconds where the remote coder can take minutes to first token — inside
3009
+ # the 20s format timeout that difference is "formatted" vs "always falls
3010
+ # back to raw text". Same split as routing/plain replies/summarization.
2964
3011
  markdown_text = _format_text_for_pdf(
2965
- doc_text, doc_title, coding_base_url, agent_api_key, coding_model_id,
3012
+ doc_text, doc_title,
3013
+ agent_base_url or coding_base_url, agent_api_key,
3014
+ agent_model_id or coding_model_id,
2966
3015
  instruction=edit_instruction,
2967
3016
  )
2968
3017
 
@@ -3223,6 +3272,8 @@ def run_agent_chat(cfg):
3223
3272
  f"- RESEARCH BUDGET — you may make at most {research_budget} web_search/fetch_url calls "
3224
3273
  "TOTAL per task (rewording a query still counts). Reserve your remaining steps for "
3225
3274
  "create_pdf / final_answer.\n"
3275
+ "- If an earlier Observation already says '✅ Your PDF … is ready', the PDF EXISTS — "
3276
+ "do NOT call create_pdf again. Pass that download link to final_answer.\n"
3226
3277
  "- Do NOT put final_answer outside the code block.\n\n"
3227
3278
  )
3228
3279
  # Recover an interrupted prior turn's step trace, if one survives on disk (crash/
@@ -3495,7 +3546,12 @@ def run_agent_chat(cfg):
3495
3546
  _log(f"fetch_url {url} → PDF, cannot read")
3496
3547
  _last_obs["text"] = msg
3497
3548
  return msg
3498
- text = _html_to_text(raw.decode("utf-8", errors="replace")) or "(page had no readable text)"
3549
+ # 8000, not the 3000 default: a research page's real content (fixture tables,
3550
+ # data) sits well past the lead, so the old cap returned only the intro/nav and
3551
+ # starved the model (task #75). Older large observations are trimmed by the #63
3552
+ # step_callback, so the extra size only costs context for the latest 2 steps.
3553
+ text = (_html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
3554
+ or "(page had no readable text)")
3499
3555
  out = f"{url}\n{text}"
3500
3556
  _log(f"fetch_url {url} → {len(text)} chars")
3501
3557
  _last_obs["text"] = out
@@ -3595,6 +3651,22 @@ def run_agent_chat(cfg):
3595
3651
  _last_obs["text"] = result
3596
3652
  return result
3597
3653
 
3654
+ # Task #74 retry guard. When a create_pdf call outlives the executor's wall clock,
3655
+ # the tool still finishes (render + S3 upload succeed, "PDF ready" is emitted) but
3656
+ # the model is told the step FAILED — so it calls create_pdf again for the same
3657
+ # document. Remember the last success here; `timeout_pending` is flipped by
3658
+ # step_callback whenever a step's result was an executor-timeout error, meaning
3659
+ # whatever succeeded during that step was never reported to the model.
3660
+ _pdf_state: dict = {"last": None, "timeout_pending": False}
3661
+
3662
+ def _pdf_sig(title: str, text: str) -> str:
3663
+ return (title.strip().lower() + "||"
3664
+ + re.sub(r"\s+", " ", (text or "").strip().lower())[:1500])
3665
+
3666
+ def _pdf_titles_similar(a: str, b: str) -> bool:
3667
+ a, b = a.strip().lower(), b.strip().lower()
3668
+ return bool(a and b) and (a == b or a in b or b in a)
3669
+
3598
3670
  @tool
3599
3671
  def create_pdf(text: str, title: str = "") -> str:
3600
3672
  """Create a PDF document from text/data and return a download link.
@@ -3608,10 +3680,46 @@ def run_agent_chat(cfg):
3608
3680
  title: Optional document title shown at the top and used in the file name.
3609
3681
  """
3610
3682
  doc_title = (title or "").strip() or "Document"
3683
+ # Retry guard (task #74): identical content is ALWAYS a retry; a merely similar
3684
+ # title counts only when the previous success was swallowed by an executor
3685
+ # timeout (the model rewords/shortens on retry, so the text rarely matches).
3686
+ prev = _pdf_state.get("last")
3687
+ if prev and (
3688
+ prev["sig"] == _pdf_sig(doc_title, text)
3689
+ or (_pdf_state.get("timeout_pending")
3690
+ and _pdf_titles_similar(prev["title"], doc_title))
3691
+ ):
3692
+ _log(f"create_pdf retry detected (prev title={prev['title']!r}) "
3693
+ "→ returning the existing PDF instead of re-rendering")
3694
+ _emit({"type": "step",
3695
+ "text": f"PDF already created → {prev['title']}.pdf (reusing link)"})
3696
+ _last_obs["text"] = prev["msg"]
3697
+ return prev["msg"]
3698
+
3699
+ # Refuse a placeholder skeleton instead of shipping a confident-but-empty PDF
3700
+ # (task #75): a table of 'TBD vs TBD' rows means the agent never found the real
3701
+ # data. 3+ placeholder cells is the fingerprint of that failure and virtually
3702
+ # never appears in a genuine user document. Steer the model to answer honestly
3703
+ # rather than render a green "✅ ready" over an empty table.
3704
+ _placeholders = len(re.findall(
3705
+ r"\b(?:TBD|TBA|TBC|N/?A)\b|\?\?\?|—\s*(?:vs\.?)?\s*—", text or "", re.I))
3706
+ if _placeholders >= 3:
3707
+ msg = (
3708
+ f"This document is mostly placeholders ({_placeholders} TBD/TBA cells) — "
3709
+ "the real data was never found, so the PDF would be empty. Do NOT retry "
3710
+ "create_pdf. Call final_answer to tell the user honestly that you could "
3711
+ f"not find the actual {doc_title} data to fill the table."
3712
+ )
3713
+ _log(f"create_pdf refused: {_placeholders} placeholder cells")
3714
+ _last_obs["text"] = msg
3715
+ return msg
3611
3716
  # 1) Format the raw text into clean Markdown (extra model call, intentional).
3717
+ # CHAT model, not the coder — see the fast-path call site for why (task #74).
3612
3718
  _emit({"type": "step", "text": "Formatting document…"})
3613
3719
  markdown_text = _format_text_for_pdf(
3614
- text or "", doc_title, coding_base_url, agent_api_key, coding_model_id
3720
+ text or "", doc_title,
3721
+ agent_base_url or coding_base_url, agent_api_key,
3722
+ agent_model_id or coding_model_id,
3615
3723
  )
3616
3724
 
3617
3725
  # 2) Render the Markdown to PDF bytes locally (pure-Python xhtml2pdf).
@@ -3655,6 +3763,9 @@ def run_agent_chat(cfg):
3655
3763
  )
3656
3764
  _emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
3657
3765
  _log(f"create_pdf ok title={doc_title!r} bytes={len(pdf_bytes)}")
3766
+ _pdf_state["last"] = {
3767
+ "sig": _pdf_sig(doc_title, text), "title": doc_title, "msg": out,
3768
+ }
3658
3769
  _last_obs["text"] = out
3659
3770
  return out
3660
3771
 
@@ -3670,6 +3781,19 @@ def run_agent_chat(cfg):
3670
3781
  def step_callback(step_log):
3671
3782
  step_num = getattr(step_log, "step_number", "?")
3672
3783
 
3784
+ # Task #74: an executor-timeout error means whatever the step's tool call
3785
+ # achieved was NEVER reported to the model (a create_pdf may have finished —
3786
+ # or may still be finishing — in the background). Flag it so a repeat
3787
+ # create_pdf with a similar title is treated as the retry it is; any step
3788
+ # that ends with a real (non-timeout) result clears the flag.
3789
+ try:
3790
+ _err = getattr(step_log, "error", None)
3791
+ _pdf_state["timeout_pending"] = bool(
3792
+ _err and "maximum execution time" in str(_err)
3793
+ )
3794
+ except Exception: # noqa: BLE001
3795
+ pass
3796
+
3673
3797
  # (B) Trim OLD, LARGE tool observations out of the CodeAgent's step memory so
3674
3798
  # they aren't re-sent to the model on every subsequent step (the #63 O(n²) token
3675
3799
  # blow-up: an uncapped read_file_lines dump cost ~5-6k tokens PER remaining step).
@@ -5122,7 +5246,15 @@ def run_agent_chat(cfg):
5122
5246
  )
5123
5247
  if _AgentBase is CodeAgent:
5124
5248
  # Only the CodeAgent runs a Python executor; ToolCallingAgent takes neither.
5125
- agent_kwargs["executor_kwargs"] = {"timeout_seconds": 60}
5249
+ # 660s, NOT 60 (task #74): the wall clock must exceed the LARGEST tool-internal
5250
+ # timeout (run_command caps at 600s; create_pdf's format+render+upload can pass
5251
+ # 60s), otherwise a tool that eventually SUCCEEDS gets reported to the model as
5252
+ # 'Code execution exceeded the maximum execution time' — the model then retries
5253
+ # work that already finished (the duplicate-PDF bug). Every tool enforces its
5254
+ # own tighter timeout and returns a clean message; runaway pure-python is still
5255
+ # caught by smolagents' op-count caps (MAX_OPERATIONS/MAX_WHILE_ITERATIONS),
5256
+ # so this wall clock is a last-resort backstop, not the primary guard.
5257
+ agent_kwargs["executor_kwargs"] = {"timeout_seconds": 660}
5126
5258
  agent_kwargs["additional_authorized_imports"] = [
5127
5259
  "json", "base64", "urllib", "urllib.request", "urllib.error"
5128
5260
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.237",
3
+ "version": "1.0.239",
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",