@tiens.nguyen/gonext-local-worker 1.0.140 → 1.0.142

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.
@@ -634,6 +634,38 @@ def _strip_tool_tags(text: str) -> str:
634
634
 
635
635
  _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
636
636
 
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)
644
+ _CODE_CLOSE_TAG = re.compile(r"<\s*/\s*code\s*>", re.IGNORECASE)
645
+ # Fallback: a markdown code fence (```py / ```python / ```) when the model ignored
646
+ # the tag convention entirely. Captures the fenced body so we can re-wrap it.
647
+ _MD_FENCE_BLOCK = re.compile(
648
+ r"```(?:py|python)?[ \t]*\r?\n(.*?)```", re.DOTALL | re.IGNORECASE
649
+ )
650
+
651
+
652
+ def _normalize_code_tags(text: str) -> str:
653
+ """Coerce a model reply's code-block delimiters to smolagents' exact <code>…</code>.
654
+
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."""
661
+ if not text:
662
+ return text
663
+ fixed = _CODE_OPEN_TAG.sub("<code>", text)
664
+ fixed = _CODE_CLOSE_TAG.sub("</code>", fixed)
665
+ if "<code>" not in fixed:
666
+ fixed = _MD_FENCE_BLOCK.sub(lambda m: f"<code>\n{m.group(1)}</code>", fixed, count=1)
667
+ return fixed
668
+
637
669
 
638
670
  def _strip_think(text: str) -> str:
639
671
  """Remove Qwen3-style <think>…</think> reasoning traces from a model reply.
@@ -1773,6 +1805,85 @@ def run_agent_chat(cfg):
1773
1805
  # and any step memory it accumulates. completion_kwargs["messages"] here is the
1774
1806
  # literal messages array sent to /v1/chat/completions.
1775
1807
  class _LoggingModel(OpenAIServerModel):
1808
+ def __init__(self, *a, stream_agent=False, **kw):
1809
+ # stream_agent: request the completion with stream=True and reassemble the
1810
+ # deltas into one message. Enabled for Ollama, where a long single-shot
1811
+ # (non-stream) generation sends nothing until the very end — a reverse proxy
1812
+ # in front of the box (ollama1.gomarsic.cc) then hits its idle read-timeout
1813
+ # and kills the request mid-generation. Streaming keeps tokens flowing so the
1814
+ # idle timer never fires. smolagents still receives the COMPLETE text, so its
1815
+ # <code>/final_answer parsing is unaffected.
1816
+ self._stream_agent = bool(stream_agent)
1817
+ super().__init__(*a, **kw)
1818
+
1819
+ def _generate_once(self, *args, **kwargs):
1820
+ """One model call. Streams + reassembles when _stream_agent, else the normal
1821
+ non-streaming path. Streaming is a pure transport optimization: on ANY failure
1822
+ it falls back to the non-streaming call, and it's skipped for tool-calling
1823
+ mode (tools_to_call_from), whose tool_call deltas we don't reassemble."""
1824
+ tools_to_call_from = kwargs.get("tools_to_call_from")
1825
+ if not self._stream_agent or tools_to_call_from:
1826
+ return super().generate(*args, **kwargs)
1827
+ try:
1828
+ return self._streamed_generate(*args, **kwargs)
1829
+ except Exception as e: # noqa: BLE001
1830
+ _log(f"streamed generate failed ({e}) → falling back to non-streaming")
1831
+ return super().generate(*args, **kwargs)
1832
+
1833
+ def _streamed_generate(
1834
+ self, messages, stop_sequences=None, response_format=None,
1835
+ tools_to_call_from=None, **kwargs,
1836
+ ):
1837
+ from smolagents.models import ChatMessage, TokenUsage # local: version-safe
1838
+ completion_kwargs = self._prepare_completion_kwargs(
1839
+ messages=messages,
1840
+ stop_sequences=stop_sequences,
1841
+ response_format=response_format,
1842
+ tools_to_call_from=tools_to_call_from,
1843
+ model=self.model_id,
1844
+ custom_role_conversions=self.custom_role_conversions,
1845
+ convert_images_to_image_urls=True,
1846
+ **kwargs,
1847
+ )
1848
+ completion_kwargs["stream"] = True
1849
+ # Ask for a usage summary in the final chunk (OpenAI streaming convention;
1850
+ # Ollama honors it). Harmless if the server ignores it — usage stays 0.
1851
+ completion_kwargs["stream_options"] = {"include_usage": True}
1852
+ self._apply_rate_limit()
1853
+ stream = self.client.chat.completions.create(**completion_kwargs)
1854
+ parts = []
1855
+ role = "assistant"
1856
+ in_tok = out_tok = 0
1857
+ for chunk in stream:
1858
+ usage = getattr(chunk, "usage", None)
1859
+ if usage is not None:
1860
+ in_tok = getattr(usage, "prompt_tokens", 0) or in_tok
1861
+ out_tok = getattr(usage, "completion_tokens", 0) or out_tok
1862
+ choices = getattr(chunk, "choices", None)
1863
+ if not choices:
1864
+ continue
1865
+ delta = getattr(choices[0], "delta", None)
1866
+ if delta is None:
1867
+ continue
1868
+ if getattr(delta, "role", None):
1869
+ role = delta.role
1870
+ piece = getattr(delta, "content", None)
1871
+ if piece:
1872
+ parts.append(piece)
1873
+ content = "".join(parts)
1874
+ if stop_sequences and not self.supports_stop_parameter:
1875
+ from smolagents.models import remove_content_after_stop_sequences
1876
+ content = remove_content_after_stop_sequences(content, stop_sequences)
1877
+ _log(f"streamed generate assembled {len(content)} chars "
1878
+ f"(in={in_tok} out={out_tok} tokens)")
1879
+ return ChatMessage(
1880
+ role=role,
1881
+ content=content,
1882
+ tool_calls=None,
1883
+ raw=None,
1884
+ token_usage=TokenUsage(input_tokens=in_tok, output_tokens=out_tok),
1885
+ )
1886
+
1776
1887
  def generate(self, *args, **kwargs):
1777
1888
  # Two safeguards on each step's reply BEFORE smolagents parses it for the
1778
1889
  # code block / final_answer:
@@ -1811,7 +1922,7 @@ def run_agent_chat(cfg):
1811
1922
  try:
1812
1923
  for attempt in range(3):
1813
1924
  try:
1814
- msg = super().generate(*args, **kwargs)
1925
+ msg = self._generate_once(*args, **kwargs)
1815
1926
  break
1816
1927
  except Exception as e: # noqa: BLE001
1817
1928
  emsg = str(e)
@@ -1841,10 +1952,20 @@ def run_agent_chat(cfg):
1841
1952
  if content is None:
1842
1953
  _log("model returned content=None (empty/thinking-only turn) → coercing to ''")
1843
1954
  msg.content = ""
1955
+ content = ""
1844
1956
  elif isinstance(content, str) and "</think>" in content.lower():
1845
1957
  cleaned = _strip_think(content)
1846
1958
  _log(f"stripped <think> trace ({len(content) - len(cleaned)} chars)")
1847
1959
  msg.content = cleaned
1960
+ content = cleaned
1961
+ # Normalize code-block delimiters so smolagents' <code>…</code> parser
1962
+ # matches even when the model wrote "<code >" / a markdown fence. Without
1963
+ # this, tag-whitespace variants fail EVERY step → "Reached max steps".
1964
+ if isinstance(content, str) and content:
1965
+ normalized = _normalize_code_tags(content)
1966
+ if normalized != content:
1967
+ _log("normalized code-block tags (<code >/fence → <code>)")
1968
+ msg.content = normalized
1848
1969
  except Exception as e: # noqa: BLE001
1849
1970
  _log(f"content-normalize error: {e}")
1850
1971
  return msg
@@ -1972,13 +2093,17 @@ def run_agent_chat(cfg):
1972
2093
  # the live test — minutes per step on a slow GPU — so disable it explicitly.
1973
2094
  # Sniffed per-server because MLX might reject the unknown param.
1974
2095
  extra_model_kwargs = {}
1975
- if _is_ollama_server(coding_base_url):
1976
- _log("coding server is Ollama → sending reasoning_effort='none' to disable thinking")
2096
+ coding_is_ollama = _is_ollama_server(coding_base_url)
2097
+ if coding_is_ollama:
2098
+ _log("coding server is Ollama → reasoning_effort='none' (disable thinking) + streaming")
1977
2099
  extra_model_kwargs["reasoning_effort"] = "none"
1978
2100
  model = _LoggingModel(
1979
2101
  model_id=coding_model_id,
1980
2102
  api_base=coding_base_url,
1981
2103
  api_key=agent_api_key,
2104
+ # Stream for Ollama so a long generation keeps the connection warm and can't
2105
+ # trip a reverse-proxy idle read-timeout. smolagents still gets the full text.
2106
+ stream_agent=coding_is_ollama,
1982
2107
  # Cap each HTTP attempt at 600s so a genuinely slow single call (a
1983
2108
  # remote M6000/Vulkan box doing a cold ~4-5min prompt-eval) can finish
1984
2109
  # 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.140",
3
+ "version": "1.0.142",
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",