@tiens.nguyen/gonext-local-worker 1.0.141 → 1.0.143

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 +123 -18
  2. package/package.json +1 -1
@@ -482,6 +482,12 @@ def _summarise_step(step_log):
482
482
  err = str(error)
483
483
  if "Import of" in err and "not allowed" in err:
484
484
  parts.append("→ (import blocked — using http_request tool instead)")
485
+ elif "reached max steps" in err.lower() or "max steps" in err.lower():
486
+ # NOT a user-facing failure: hitting the step budget triggers our
487
+ # provide_final_answer override, which ALWAYS delivers a synthesized answer
488
+ # (a rendered PDF, the last tool result, or a plain reply). Surfacing the raw
489
+ # "Error: Reached max steps." made a successful run look failed (task #10).
490
+ parts.append("→ Wrapping up (reached step budget)…")
485
491
  else:
486
492
  parts.append(f"→ Error: {err[:120]}")
487
493
 
@@ -634,14 +640,23 @@ def _strip_tool_tags(text: str) -> str:
634
640
 
635
641
  _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
636
642
 
637
- # Whitespace/attr variants of smolagents' code-block tags. Weak models routinely
638
- # emit "<code >" (trailing space), "< code >", "</ code>", or an uppercase form
639
- # none of which match smolagents' exact "<code>(.*?)</code>" parser, so EVERY step
640
- # fails with "regex pattern <code>(.*?)</code> was not found" and the run burns all
641
- # its steps. Normalizing the tags to the canonical form before the parser sees them
642
- # turns that spiral into a normal, parseable step.
643
- _CODE_OPEN_TAG = re.compile(r"<\s*code(?:\s[^>]*)?\s*>", re.IGNORECASE)
643
+ # Malformed variants of smolagents' code-block tags. Weak coding models routinely
644
+ # emit "<code >" (trailing space), "< code >", "</ code>", "<CODE>", OR worst
645
+ # "<code print(x) </code>" where the OPENING tag is missing its ">" entirely. None
646
+ # match smolagents' exact "<code>(.*?)</code>" parser, so EVERY step fails with
647
+ # "regex pattern <code>(.*?)</code> was not found" and the run burns all its steps.
648
+ # Normalizing the tags to canonical form before the parser sees them turns that
649
+ # spiral into a normal, parseable step.
644
650
  _CODE_CLOSE_TAG = re.compile(r"<\s*/\s*code\s*>", re.IGNORECASE)
651
+ # A well-formed-ISH open tag that DOES have a ">": "<code>", "<code >", "< code >",
652
+ # "<CODE>", "<code class=py>". Critically the attr span is [^<>] (NOT [^>]) so it can
653
+ # never swallow across the "<" of a following "</code>" — the bug that collapsed
654
+ # "<code print(x) </code>" to a bare "<code>" and destroyed the code.
655
+ _CODE_OPEN_TAG = re.compile(r"<\s*code\b[^<>]*>", re.IGNORECASE)
656
+ # An open tag MISSING its ">": "<code" not immediately followed by ">". \b keeps it
657
+ # from matching "<codeblock"/"<coder"; the lookahead skips the already-canonical
658
+ # "<code>" so this pass only rescues the no-">" case.
659
+ _CODE_OPEN_NO_GT = re.compile(r"<\s*code\b(?!>)", re.IGNORECASE)
645
660
  # Fallback: a markdown code fence (```py / ```python / ```) when the model ignored
646
661
  # the tag convention entirely. Captures the fenced body so we can re-wrap it.
647
662
  _MD_FENCE_BLOCK = re.compile(
@@ -652,18 +667,25 @@ _MD_FENCE_BLOCK = re.compile(
652
667
  def _normalize_code_tags(text: str) -> str:
653
668
  """Coerce a model reply's code-block delimiters to smolagents' exact <code>…</code>.
654
669
 
655
- Handles the two failure modes seen live with weak coding models:
656
- 1. tag whitespace/case: "<code >", "< code >", "</ code>", "<CODE>" "<code>".
657
- 2. markdown fences instead of tags: ```py … ``` → <code> </code> (only when
658
- no <code> tag is present after step 1, so we don't touch code that legitimately
659
- contains backticks inside a real <code> block).
660
- Returns text unchanged when it already parses cleanly."""
670
+ Handles the failure modes seen live with weak coding models:
671
+ 1. close-tag variants: "</ code>", "< / code >" "</code>" (done FIRST so the
672
+ open-tag pass has a canonical "<" boundary to stop at).
673
+ 2. open-tag-with-">": "<code >", "< code >", "<CODE>", "<code attr>" "<code>".
674
+ 3. open-tag-missing-">": "<code print(x) </code>" → "<code> print(x) </code>".
675
+ 4. markdown fences instead of tags: ```py … ``` → <code>…</code> (only when no
676
+ <code> tag is present, so we don't touch backticks inside a real <code> block).
677
+ 5. opened-but-never-closed (exactly one "<code>", no "</code>"): append "</code>"
678
+ so the parser can still extract the code instead of failing the whole step.
679
+ Idempotent — already-correct "<code>…</code>" is returned unchanged."""
661
680
  if not text:
662
681
  return text
663
- fixed = _CODE_OPEN_TAG.sub("<code>", text)
664
- fixed = _CODE_CLOSE_TAG.sub("</code>", fixed)
682
+ fixed = _CODE_CLOSE_TAG.sub("</code>", text)
683
+ fixed = _CODE_OPEN_TAG.sub("<code>", fixed)
684
+ fixed = _CODE_OPEN_NO_GT.sub("<code>", fixed)
665
685
  if "<code>" not in fixed:
666
686
  fixed = _MD_FENCE_BLOCK.sub(lambda m: f"<code>\n{m.group(1)}</code>", fixed, count=1)
687
+ if fixed.count("<code>") == 1 and "</code>" not in fixed:
688
+ fixed = fixed + "\n</code>"
667
689
  return fixed
668
690
 
669
691
 
@@ -1805,6 +1827,85 @@ def run_agent_chat(cfg):
1805
1827
  # and any step memory it accumulates. completion_kwargs["messages"] here is the
1806
1828
  # literal messages array sent to /v1/chat/completions.
1807
1829
  class _LoggingModel(OpenAIServerModel):
1830
+ def __init__(self, *a, stream_agent=False, **kw):
1831
+ # stream_agent: request the completion with stream=True and reassemble the
1832
+ # deltas into one message. Enabled for Ollama, where a long single-shot
1833
+ # (non-stream) generation sends nothing until the very end — a reverse proxy
1834
+ # in front of the box (ollama1.gomarsic.cc) then hits its idle read-timeout
1835
+ # and kills the request mid-generation. Streaming keeps tokens flowing so the
1836
+ # idle timer never fires. smolagents still receives the COMPLETE text, so its
1837
+ # <code>/final_answer parsing is unaffected.
1838
+ self._stream_agent = bool(stream_agent)
1839
+ super().__init__(*a, **kw)
1840
+
1841
+ def _generate_once(self, *args, **kwargs):
1842
+ """One model call. Streams + reassembles when _stream_agent, else the normal
1843
+ non-streaming path. Streaming is a pure transport optimization: on ANY failure
1844
+ it falls back to the non-streaming call, and it's skipped for tool-calling
1845
+ mode (tools_to_call_from), whose tool_call deltas we don't reassemble."""
1846
+ tools_to_call_from = kwargs.get("tools_to_call_from")
1847
+ if not self._stream_agent or tools_to_call_from:
1848
+ return super().generate(*args, **kwargs)
1849
+ try:
1850
+ return self._streamed_generate(*args, **kwargs)
1851
+ except Exception as e: # noqa: BLE001
1852
+ _log(f"streamed generate failed ({e}) → falling back to non-streaming")
1853
+ return super().generate(*args, **kwargs)
1854
+
1855
+ def _streamed_generate(
1856
+ self, messages, stop_sequences=None, response_format=None,
1857
+ tools_to_call_from=None, **kwargs,
1858
+ ):
1859
+ from smolagents.models import ChatMessage, TokenUsage # local: version-safe
1860
+ completion_kwargs = self._prepare_completion_kwargs(
1861
+ messages=messages,
1862
+ stop_sequences=stop_sequences,
1863
+ response_format=response_format,
1864
+ tools_to_call_from=tools_to_call_from,
1865
+ model=self.model_id,
1866
+ custom_role_conversions=self.custom_role_conversions,
1867
+ convert_images_to_image_urls=True,
1868
+ **kwargs,
1869
+ )
1870
+ completion_kwargs["stream"] = True
1871
+ # Ask for a usage summary in the final chunk (OpenAI streaming convention;
1872
+ # Ollama honors it). Harmless if the server ignores it — usage stays 0.
1873
+ completion_kwargs["stream_options"] = {"include_usage": True}
1874
+ self._apply_rate_limit()
1875
+ stream = self.client.chat.completions.create(**completion_kwargs)
1876
+ parts = []
1877
+ role = "assistant"
1878
+ in_tok = out_tok = 0
1879
+ for chunk in stream:
1880
+ usage = getattr(chunk, "usage", None)
1881
+ if usage is not None:
1882
+ in_tok = getattr(usage, "prompt_tokens", 0) or in_tok
1883
+ out_tok = getattr(usage, "completion_tokens", 0) or out_tok
1884
+ choices = getattr(chunk, "choices", None)
1885
+ if not choices:
1886
+ continue
1887
+ delta = getattr(choices[0], "delta", None)
1888
+ if delta is None:
1889
+ continue
1890
+ if getattr(delta, "role", None):
1891
+ role = delta.role
1892
+ piece = getattr(delta, "content", None)
1893
+ if piece:
1894
+ parts.append(piece)
1895
+ content = "".join(parts)
1896
+ if stop_sequences and not self.supports_stop_parameter:
1897
+ from smolagents.models import remove_content_after_stop_sequences
1898
+ content = remove_content_after_stop_sequences(content, stop_sequences)
1899
+ _log(f"streamed generate assembled {len(content)} chars "
1900
+ f"(in={in_tok} out={out_tok} tokens)")
1901
+ return ChatMessage(
1902
+ role=role,
1903
+ content=content,
1904
+ tool_calls=None,
1905
+ raw=None,
1906
+ token_usage=TokenUsage(input_tokens=in_tok, output_tokens=out_tok),
1907
+ )
1908
+
1808
1909
  def generate(self, *args, **kwargs):
1809
1910
  # Two safeguards on each step's reply BEFORE smolagents parses it for the
1810
1911
  # code block / final_answer:
@@ -1843,7 +1944,7 @@ def run_agent_chat(cfg):
1843
1944
  try:
1844
1945
  for attempt in range(3):
1845
1946
  try:
1846
- msg = super().generate(*args, **kwargs)
1947
+ msg = self._generate_once(*args, **kwargs)
1847
1948
  break
1848
1949
  except Exception as e: # noqa: BLE001
1849
1950
  emsg = str(e)
@@ -2014,13 +2115,17 @@ def run_agent_chat(cfg):
2014
2115
  # the live test — minutes per step on a slow GPU — so disable it explicitly.
2015
2116
  # Sniffed per-server because MLX might reject the unknown param.
2016
2117
  extra_model_kwargs = {}
2017
- if _is_ollama_server(coding_base_url):
2018
- _log("coding server is Ollama → sending reasoning_effort='none' to disable thinking")
2118
+ coding_is_ollama = _is_ollama_server(coding_base_url)
2119
+ if coding_is_ollama:
2120
+ _log("coding server is Ollama → reasoning_effort='none' (disable thinking) + streaming")
2019
2121
  extra_model_kwargs["reasoning_effort"] = "none"
2020
2122
  model = _LoggingModel(
2021
2123
  model_id=coding_model_id,
2022
2124
  api_base=coding_base_url,
2023
2125
  api_key=agent_api_key,
2126
+ # Stream for Ollama so a long generation keeps the connection warm and can't
2127
+ # trip a reverse-proxy idle read-timeout. smolagents still gets the full text.
2128
+ stream_agent=coding_is_ollama,
2024
2129
  # Cap each HTTP attempt at 600s so a genuinely slow single call (a
2025
2130
  # remote M6000/Vulkan box doing a cold ~4-5min prompt-eval) can finish
2026
2131
  # on the first try, while a truly hung request still fails into OUR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.141",
3
+ "version": "1.0.143",
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",