@tiens.nguyen/gonext-local-worker 1.0.124 → 1.0.126

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 +126 -50
  2. package/package.json +1 -1
@@ -827,6 +827,33 @@ _PDF_READ_INTENT = re.compile(
827
827
  )
828
828
 
829
829
 
830
+ # A PDF request whose CONTENT must be gathered/researched first (e.g. "get me the world
831
+ # cup 2026 schedule … then create a pdf"). The deterministic fast-path can only render text
832
+ # the user already GAVE (or a prior assistant turn) — it cannot web_search, and the model
833
+ # would just hallucinate live facts. When these retrieval/currency signals appear AND no
834
+ # document body was provided, we skip the fast-path and let the multi-step agent gather the
835
+ # data (web_search / fetch_url) and THEN call create_pdf with it.
836
+ _PDF_RESEARCH_INTENT = re.compile(
837
+ r"\b(get me|get the|find|research|look ?up|search|fetch|gather|collect|compile|"
838
+ r"latest|current|today'?s|upcoming|recent|schedules?|fixtures?|standings?|"
839
+ r"news|weather|prices?|scores?|results?|rankings?|statistics|stats)\b",
840
+ re.IGNORECASE,
841
+ )
842
+
843
+
844
+ def _pdf_has_explicit_body(user_text: str) -> bool:
845
+ """True when the message itself supplies the document body — a quoted span, or text
846
+ after a 'text:/content:/following:/data:' lead-in. Distinguishes 'make a PDF of THIS
847
+ text …' (fast-path OK) from 'get me X then make a PDF' (needs the agent to fetch X)."""
848
+ t = user_text or ""
849
+ for pat in (r'"([\s\S]+)"', r"“([\s\S]+)”", r"'([\s\S]+)'"):
850
+ m = re.search(pat, t)
851
+ if m and len(m.group(1).strip()) >= 40:
852
+ return True
853
+ m = re.search(r"\b(texts?|following|below|content|data)\b\s*[:\-]\s*\S", t, re.IGNORECASE)
854
+ return bool(m)
855
+
856
+
830
857
  def _wants_pdf(text: str) -> bool:
831
858
  """True when the user is asking us to (re)generate a PDF — fresh request or a
832
859
  follow-up referencing a target PDF — but NOT when merely asking about one, and NOT
@@ -1079,8 +1106,8 @@ def run_agent_chat(cfg):
1079
1106
  # the most recent substantial assistant turn as the base document and treat the
1080
1107
  # current user message as an edit instruction the formatter should apply.
1081
1108
  edit_instruction = ""
1109
+ prior_doc = ""
1082
1110
  if len(doc_text.strip()) < 40:
1083
- prior_doc = ""
1084
1111
  for m in reversed(messages[:last_user_idx]):
1085
1112
  if m.get("role") != "assistant":
1086
1113
  continue
@@ -1095,52 +1122,62 @@ def run_agent_chat(cfg):
1095
1122
  if prior_doc:
1096
1123
  edit_instruction = latest_user_text.strip()
1097
1124
  doc_text = prior_doc
1098
- doc_title = _derive_pdf_title(doc_text)
1099
- _log(
1100
- f"PDF fast-path: title={doc_title!r} doc_chars={len(doc_text)}"
1101
- + (f" edit={edit_instruction[:60]!r}" if edit_instruction else "")
1102
- )
1103
1125
 
1104
- _emit({"type": "step", "text": "Formatting document…"})
1105
- markdown_text = _format_text_for_pdf(
1106
- doc_text, doc_title, coding_base_url, agent_api_key, coding_model_id,
1107
- instruction=edit_instruction,
1108
- )
1126
+ # Guard: if the PDF's content must be RESEARCHED (not provided in the message, not
1127
+ # from a prior turn), don't render the instruction text into a PDF — fall through to
1128
+ # the agent loop so it can web_search/fetch_url the facts and THEN create_pdf them.
1129
+ if (not prior_doc
1130
+ and not _pdf_has_explicit_body(latest_user_text)
1131
+ and _PDF_RESEARCH_INTENT.search(latest_user_text)):
1132
+ _log("PDF fast-path SKIPPED: content needs research (no provided body) "
1133
+ "→ agent loop (web_search → create_pdf)")
1134
+ else:
1135
+ doc_title = _derive_pdf_title(doc_text)
1136
+ _log(
1137
+ f"PDF fast-path: title={doc_title!r} doc_chars={len(doc_text)}"
1138
+ + (f" edit={edit_instruction[:60]!r}" if edit_instruction else "")
1139
+ )
1109
1140
 
1110
- _emit({"type": "step", "text": "Rendering PDF…"})
1111
- try:
1112
- pdf_bytes = _render_pdf_bytes(markdown_text, doc_title)
1113
- except RuntimeError as e:
1114
- msg = str(e)
1115
- _log(f"PDF fast-path render error: {msg[:200]}")
1116
- _emit({"type": "final", "text": msg})
1117
- return
1118
- except Exception as e: # noqa: BLE001
1119
- _log(f"PDF fast-path render crash: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
1120
- _emit({"type": "final", "text": (
1121
- f"Sorry — I couldn't render that into a PDF ({type(e).__name__}: {str(e)[:160]})."
1122
- )})
1123
- return
1141
+ _emit({"type": "step", "text": "Formatting document…"})
1142
+ markdown_text = _format_text_for_pdf(
1143
+ doc_text, doc_title, coding_base_url, agent_api_key, coding_model_id,
1144
+ instruction=edit_instruction,
1145
+ )
1124
1146
 
1125
- _emit({"type": "step", "text": "Uploading PDF…"})
1126
- try:
1127
- download_url = _pdf_upload_via_api(
1128
- pdf_api_base, pdf_worker_key, f"{doc_title}.pdf", pdf_bytes
1147
+ _emit({"type": "step", "text": "Rendering PDF…"})
1148
+ try:
1149
+ pdf_bytes = _render_pdf_bytes(markdown_text, doc_title)
1150
+ except RuntimeError as e:
1151
+ msg = str(e)
1152
+ _log(f"PDF fast-path render error: {msg[:200]}")
1153
+ _emit({"type": "final", "text": msg})
1154
+ return
1155
+ except Exception as e: # noqa: BLE001
1156
+ _log(f"PDF fast-path render crash: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
1157
+ _emit({"type": "final", "text": (
1158
+ f"Sorry — I couldn't render that into a PDF ({type(e).__name__}: {str(e)[:160]})."
1159
+ )})
1160
+ return
1161
+
1162
+ _emit({"type": "step", "text": "Uploading PDF…"})
1163
+ try:
1164
+ download_url = _pdf_upload_via_api(
1165
+ pdf_api_base, pdf_worker_key, f"{doc_title}.pdf", pdf_bytes
1166
+ )
1167
+ except Exception as e: # noqa: BLE001
1168
+ _log(f"PDF fast-path upload error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
1169
+ _emit({"type": "final", "text": (
1170
+ f"PDF was created but could not be uploaded: {str(e)[:200]}"
1171
+ )})
1172
+ return
1173
+
1174
+ out = (
1175
+ f"✅ Your PDF \"{doc_title}\" is ready.\n"
1176
+ f"Download it here (link valid for a limited time):\n{download_url}"
1129
1177
  )
1130
- except Exception as e: # noqa: BLE001
1131
- _log(f"PDF fast-path upload error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
1132
- _emit({"type": "final", "text": (
1133
- f"PDF was created but could not be uploaded: {str(e)[:200]}"
1134
- )})
1178
+ _log(f"PDF fast-path ok title={doc_title!r} bytes={len(pdf_bytes)}")
1179
+ _emit({"type": "final", "text": out})
1135
1180
  return
1136
-
1137
- out = (
1138
- f"✅ Your PDF \"{doc_title}\" is ready.\n"
1139
- f"Download it here (link valid for a limited time):\n{download_url}"
1140
- )
1141
- _log(f"PDF fast-path ok title={doc_title!r} bytes={len(pdf_bytes)}")
1142
- _emit({"type": "final", "text": out})
1143
- return
1144
1181
  # ---------------------------------------------------------------------------------
1145
1182
 
1146
1183
  # Prepend explicit tool instructions so small models pick the right tool, never
@@ -1217,6 +1254,9 @@ def run_agent_chat(cfg):
1217
1254
  "- a live/current NUMBER with no URL given (price, exchange rate, weather, score, "
1218
1255
  "stock/crypto) -> web_search(query). Do NOT guess an API URL for these.\n"
1219
1256
  "- 'create/make/generate/export a PDF' of some text/data -> create_pdf(text, title).\n"
1257
+ "- a PDF of info you must LOOK UP first (e.g. 'get the world cup schedule then make a "
1258
+ "pdf') -> FIRST web_search/fetch_url to gather the real facts, THEN in a later step "
1259
+ "create_pdf(text=<the gathered facts>, title=...). Never PDF the request wording itself.\n"
1220
1260
  + _pdf_read_choose_line + _email_choose_line +
1221
1261
  "- A specific known API/URL was given -> http_request().\n"
1222
1262
  "\n"
@@ -1574,22 +1614,58 @@ def run_agent_chat(cfg):
1574
1614
  # literal messages array sent to /v1/chat/completions.
1575
1615
  class _LoggingModel(OpenAIServerModel):
1576
1616
  def generate(self, *args, **kwargs):
1577
- # Strip Qwen3 <think>…</think> reasoning traces from each step's reply
1578
- # BEFORE smolagents parses it for the code block / final_answer. Thinking
1579
- # stays on (better reasoning), but the trace never confuses the parser or
1580
- # leaks to the user.
1617
+ # Two safeguards on each step's reply BEFORE smolagents parses it for the
1618
+ # code block / final_answer:
1619
+ # 1. content=None guard — Qwen3 (and reasoning models generally) can return
1620
+ # message.content=None on a turn (e.g. it spent the whole completion on a
1621
+ # <think> trace and never emitted a code block). smolagents has NO null
1622
+ # guard (models.py: `content = response.choices[0].message.content`) and
1623
+ # feeds it straight into parse_code_blobs → `re` gets None →
1624
+ # "expected string or bytes-like object, got 'NoneType'" → the whole
1625
+ # loop spirals on parse errors and trips the WebSocket timeout. Coercing
1626
+ # None→"" turns that fatal crash into a normal "no code block" retry.
1627
+ # 2. strip any leftover Qwen3 <think>…</think> trace (belt-and-suspenders;
1628
+ # thinking is disabled via /no_think in _prepare_completion_kwargs, but a
1629
+ # stray trace must never reach the parser or leak to the user).
1581
1630
  msg = super().generate(*args, **kwargs)
1582
1631
  try:
1583
- if isinstance(getattr(msg, "content", None), str) and "</think>" in msg.content.lower():
1584
- cleaned = _strip_think(msg.content)
1585
- _log(f"stripped <think> trace ({len(msg.content) - len(cleaned)} chars)")
1632
+ content = getattr(msg, "content", None)
1633
+ if content is None:
1634
+ _log("model returned content=None (empty/thinking-only turn) → coercing to ''")
1635
+ msg.content = ""
1636
+ elif isinstance(content, str) and "</think>" in content.lower():
1637
+ cleaned = _strip_think(content)
1638
+ _log(f"stripped <think> trace ({len(content) - len(cleaned)} chars)")
1586
1639
  msg.content = cleaned
1587
1640
  except Exception as e: # noqa: BLE001
1588
- _log(f"think-strip error: {e}")
1641
+ _log(f"content-normalize error: {e}")
1589
1642
  return msg
1590
1643
 
1591
1644
  def _prepare_completion_kwargs(self, *args, **kwargs):
1592
1645
  ck = super()._prepare_completion_kwargs(*args, **kwargs)
1646
+ # Disable Qwen3 "thinking" for the agent loop. Live runs showed thinking-on
1647
+ # spends the whole completion budget on a <think> trace and returns
1648
+ # content=None (no code block) → parse-error spiral → WS timeout. The
1649
+ # `/no_think` soft switch (recognized by the Qwen3 chat template) forces a
1650
+ # direct Thought+code reply every step. Injected into the SYSTEM message so it
1651
+ # applies to EVERY step's request, not just the first user turn.
1652
+ try:
1653
+ msgs = ck.get("messages", []) or []
1654
+ for m in msgs:
1655
+ role = m.get("role") if isinstance(m, dict) else getattr(m, "role", None)
1656
+ if str(role).endswith("system") or role == "system":
1657
+ c = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
1658
+ if isinstance(c, str) and "/no_think" not in c:
1659
+ m["content"] = c + "\n\n/no_think"
1660
+ elif isinstance(c, list):
1661
+ for part in c:
1662
+ if isinstance(part, dict) and isinstance(part.get("text"), str) \
1663
+ and "/no_think" not in part["text"]:
1664
+ part["text"] += "\n\n/no_think"
1665
+ break
1666
+ break
1667
+ except Exception as e: # noqa: BLE001
1668
+ _log(f"/no_think inject error: {e}")
1593
1669
  try:
1594
1670
  msgs = ck.get("messages", []) or []
1595
1671
  _log(f"=== MODEL REQUEST: {len(msgs)} message(s) sent to the model ===")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.124",
3
+ "version": "1.0.126",
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",