@tiens.nguyen/gonext-local-worker 1.0.318 → 1.0.322

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 +482 -62
  2. package/package.json +1 -1
@@ -25,6 +25,7 @@ Emits NDJSON lines on stdout:
25
25
  """
26
26
  import contextlib
27
27
  import json
28
+ import random
28
29
  import re
29
30
  import sys
30
31
  import threading
@@ -820,6 +821,22 @@ class _AgentConfigError(RuntimeError):
820
821
  worth a plain-reply degrade — the user needs the message itself to fix Settings."""
821
822
 
822
823
 
824
+ class _AgentBackendOverloaded(RuntimeError):
825
+ """The coding backend refused every attempt with 429/rate-limit (task #114). Carries
826
+ the user-facing explanation; like a gateway outage it must NOT degrade to a plain
827
+ reply, which would answer from the chat model as if the work had been done."""
828
+
829
+
830
+ def _host_of(url) -> str:
831
+ """Just the hostname of a base URL ('https://api.moonshot.ai/v1' → 'api.moonshot.ai'),
832
+ for user-facing messages. Falls back to the raw string."""
833
+ try:
834
+ from urllib.parse import urlparse
835
+ return urlparse(str(url or "")).netloc or str(url or "")
836
+ except Exception: # noqa: BLE001
837
+ return str(url or "")
838
+
839
+
823
840
  def _list_model_ids(base_url, api_key=""):
824
841
  """Return the model ids an OpenAI-compatible server reports at {base_url}/models.
825
842
 
@@ -1452,8 +1469,92 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
1452
1469
  # • a genuine model/agent error — worth the plain-reply degrade.
1453
1470
  # `str(e)` for a proxy error is the raw nginx HTML ("<html>…502 Bad Gateway…"), so match
1454
1471
  # on the status text/codes and the common connection-failure phrases.
1472
+ def _is_backend_overloaded(err) -> bool:
1473
+ """True when the model server ACCEPTED the connection but REFUSED the work because it
1474
+ is saturated or we are over a quota — 429 / engine_overloaded / rate limit (task #114).
1475
+
1476
+ Deliberately separate from _is_backend_unavailable: that one means "the box or proxy
1477
+ is not answering" (retry the same request, it will probably land), while this means
1478
+ "the server is telling us to slow down" (re-sending immediately makes it worse and can
1479
+ deepen the limit). The two get different retry budgets and different user-facing copy.
1480
+
1481
+ Live trigger: Moonshot returned
1482
+ Error code: 429 - {'error': {'message': 'The engine is currently overloaded, please
1483
+ try again later', 'type': 'engine_overloaded_error'}}
1484
+ A local Ollama/MLX box does not produce these, so nothing keyed off this fires there."""
1485
+ s = str(err or "").lower()
1486
+ if "429" in s or "too many requests" in s:
1487
+ return True
1488
+ return (
1489
+ "engine_overloaded" in s
1490
+ or "overloaded" in s
1491
+ or "rate limit" in s
1492
+ or "rate_limit" in s
1493
+ # OpenAI's billing/quota code. NOT a bare "quota" — a tool hitting "Disk quota
1494
+ # exceeded" would otherwise be reported to the user as a model overload.
1495
+ or "insufficient_quota" in s
1496
+ or "over capacity" in s
1497
+ or "at capacity" in s
1498
+ )
1499
+
1500
+
1501
+ def _retry_after_seconds(err):
1502
+ """The provider's Retry-After hint in seconds, or None. Reads the header off an
1503
+ openai-python APIStatusError (`err.response.headers`) and falls back to a
1504
+ 'retry after 12 seconds' phrase in the message. Values are clamped to a sane range so
1505
+ a bogus header can't park the turn for an hour."""
1506
+ val = None
1507
+ resp = getattr(err, "response", None)
1508
+ headers = getattr(resp, "headers", None)
1509
+ if headers is not None:
1510
+ try:
1511
+ val = headers.get("retry-after") or headers.get("Retry-After")
1512
+ except Exception: # noqa: BLE001
1513
+ val = None
1514
+ if val is None:
1515
+ m = re.search(r"retry[\s-]?after[\s:]+(\d+(?:\.\d+)?)", str(err or ""), re.I)
1516
+ val = m.group(1) if m else None
1517
+ if val is None:
1518
+ return None
1519
+ try:
1520
+ secs = float(str(val).strip())
1521
+ except (TypeError, ValueError):
1522
+ return None # HTTP-date form — not worth parsing; fall back to our own backoff
1523
+ if secs <= 0:
1524
+ return None
1525
+ return max(1.0, min(60.0, secs))
1526
+
1527
+
1528
+ def _retry_delay(attempt: int, overloaded: bool, gateway: bool, err=None) -> float:
1529
+ """Seconds to wait before retry #attempt (1-based). Pure, so the policy can be
1530
+ replayed offline (task #114).
1531
+
1532
+ overloaded (429) — PATIENT and jittered: ~5s → 10s → 20s → 30s (capped), plus up to
1533
+ 25% jitter so parallel workers don't retry in lock-step. The
1534
+ provider's own Retry-After wins outright when it sends one.
1535
+ gateway (502…) — unchanged from #94: prompt re-send, 2s/4s/6s/8s capped at 8.
1536
+ otherwise — unchanged: 1.5s per attempt.
1537
+
1538
+ Only the `overloaded` branch is new; the other two must stay byte-for-byte, since they
1539
+ are the path an Ollama/MLX coder takes."""
1540
+ if overloaded:
1541
+ hinted = _retry_after_seconds(err)
1542
+ if hinted:
1543
+ return hinted
1544
+ delay = min(30.0, 5.0 * (2 ** (attempt - 1)))
1545
+ return delay + random.uniform(0, min(3.0, delay * 0.25))
1546
+ if gateway:
1547
+ return min(8.0, 2.0 * attempt)
1548
+ return 1.5 * attempt
1549
+
1550
+
1455
1551
  def _is_backend_unavailable(err) -> bool:
1456
1552
  s = str(err or "").lower()
1553
+ # An overload (429) is its own class — never let it fall into the gateway bucket, whose
1554
+ # policy (re-send promptly, up to 6 times) is exactly wrong for a server saying "slow
1555
+ # down". Checked FIRST because a 429 body can also contain the word "timeout".
1556
+ if _is_backend_overloaded(err):
1557
+ return False
1457
1558
  return (
1458
1559
  "502 bad gateway" in s
1459
1560
  or "503 service" in s
@@ -1690,6 +1791,146 @@ def _repair_code_block_strings(text: str) -> str:
1690
1791
  return text[:open_end] + sanitized + text[last:]
1691
1792
 
1692
1793
 
1794
+ def _looks_like_tool_code(code: str):
1795
+ """Is `code` plausibly a runnable tool call, i.e. worth substituting into the step?
1796
+
1797
+ Returns (ok, why_not). Used ONLY to vet a block recovered from a reasoning channel
1798
+ (task #113) — never to judge code the model put in the visible message, which keeps
1799
+ the normal path (and Ollama) byte-for-byte unchanged.
1800
+
1801
+ Why this gate exists: a reasoning model narrates the FORMAT rules to itself, so the
1802
+ text between a <code>…</code> pair in its reasoning is often English, not python.
1803
+ Seen live with Kimi K3, recovering the 16 chars "and closing with" (it was echoing
1804
+ the system prompt's "OPENS with <code> and CLOSES with </code>") — we wrapped that
1805
+ prose in tags and executed it, turning a clean parse error into a SyntaxError and a
1806
+ poisoned history. Substituting un-vetted prose is strictly worse than not recovering.
1807
+
1808
+ Two checks, both cheap:
1809
+ 1. it must COMPILE as python (kills prose, which is the actual failure mode);
1810
+ 2. it must CALL something (`name(`) — every legitimate CodeAgent step calls a tool
1811
+ or final_answer, while a bare word like "yes" happens to compile as a Name."""
1812
+ code = (code or "").strip()
1813
+ if not code:
1814
+ return False, "empty"
1815
+ try:
1816
+ compile(code, "<gonext-recovered>", "exec")
1817
+ except SyntaxError as e:
1818
+ return False, f"not python ({(getattr(e, 'msg', '') or 'syntax error')})"
1819
+ except Exception as e: # noqa: BLE001
1820
+ return False, f"not compilable ({e})"
1821
+ if not re.search(r"[A-Za-z_][A-Za-z0-9_]*\s*\(", code):
1822
+ return False, "no function call"
1823
+ return True, ""
1824
+
1825
+
1826
+ def _has_runnable_block(text) -> bool:
1827
+ """True if `text` contains a <code>…</code> block whose python actually compiles —
1828
+ i.e. this step produced something that can run. Used by the #113 no-code loop-breaker
1829
+ (openai backend only) to tell "the step worked" from "the model wrote prose again"."""
1830
+ if not isinstance(text, str) or not text:
1831
+ return False
1832
+ blk = re.search(r"<code[^>]*>([\s\S]*?)</code>", text, re.I)
1833
+ if not blk:
1834
+ return False
1835
+ try:
1836
+ compile(blk.group(1), "<gonext-code>", "exec")
1837
+ return True
1838
+ except Exception: # noqa: BLE001
1839
+ return False
1840
+
1841
+
1842
+ def _code_nudge_reason(content, openai_backend: bool):
1843
+ """Why (if at all) this streamed turn should be re-prompted once with the CODE-NOW
1844
+ directive — or None to accept it as-is. Pure decision so the three triggers can be
1845
+ replayed offline against real traces (task #113).
1846
+
1847
+ 1. empty-content — the whole turn went to the reasoning channel, no message.
1848
+ 2. dangling tag — a stray <code>/</code> with no valid pair.
1849
+ 3. prose-only plan — narration of what it is ABOUT to do, no tag at all. This one
1850
+ is openai-only and additionally requires _looks_like_scratchpad, so a FINISHED
1851
+ prose answer is never nudged into inventing another tool call. It exists because
1852
+ the "</code>" visible in a smolagents trace is appended by smolagents AFTER the
1853
+ model returns, so trigger 2 cannot see this shape.
1854
+
1855
+ Triggers 1 and 2 keep their existing backend-agnostic behavior — the Ollama path is
1856
+ unchanged, since trigger 3 is the only new one and it is gated off there."""
1857
+ text = (content or "").strip()
1858
+ if not text:
1859
+ return "empty-content"
1860
+ if re.search(r"<code[^>]*>[\s\S]*?</code>", content, re.I):
1861
+ return None # a real block — nothing to nudge
1862
+ if re.search(r"</?code[\s>/]", content, re.I):
1863
+ return "dangling code tag, no valid block"
1864
+ if openai_backend and _looks_like_scratchpad(text):
1865
+ return "prose-only plan, no code block"
1866
+ return None
1867
+
1868
+
1869
+ def _recover_code_from_reasoning(content, reasoning):
1870
+ """Recover a <code>…</code> tool call that a reasoning model placed in the
1871
+ REASONING channel instead of the visible message (task #111). No-op when the
1872
+ visible content already has a usable code block, or when there is no reasoning
1873
+ text (e.g. Ollama with reasoning_effort='none') — so the Ollama path is unchanged.
1874
+
1875
+ Kimi K3 @ Moonshot splits its output two ways when it fails: (a) the full
1876
+ <code>…</code> block lands in reasoning and only prose leaks to content; or
1877
+ (b) the OPENER + code land in reasoning while the CLOSING </code> leaks into
1878
+ content (seen live: content = 'Thought: …contains.</code>'). Handle both.
1879
+
1880
+ Every candidate is VETTED with _looks_like_tool_code before it is used (task
1881
+ #113): the same reasoning stream also contains the model narrating the format
1882
+ rules to itself, so a <code>…</code> pair found there can hold plain English.
1883
+ Candidates are tried NEWEST-first and the first runnable one wins; if none is
1884
+ runnable we return `content` untouched and let the step fail normally, which
1885
+ is what happened before #111 existed."""
1886
+ _pair = r"<code[^>]*>[\s\S]*?</code>"
1887
+ if re.search(_pair, content or "", re.I):
1888
+ return content # content already has a usable block — never override it
1889
+ reasoning = reasoning or ""
1890
+ if not reasoning.strip():
1891
+ return content # nothing to recover from (Ollama / non-reasoning model)
1892
+
1893
+ def _clean(raw):
1894
+ # A leaked 'Thought:' line (or a stray </code>) can ride along after the
1895
+ # code — cut it so only the executable snippet remains.
1896
+ out = re.split(r"\n\s*Thought\s*:\s", raw, maxsplit=1)[0]
1897
+ return re.sub(r"</?code[^>]*>", "", out, flags=re.I).strip()
1898
+
1899
+ # (a) COMPLETE blocks inside reasoning (the model drafts the whole tool call
1900
+ # there), newest first. (b) As a last resort, the opener + code in reasoning
1901
+ # with the closer leaked into content → everything after the LAST opener.
1902
+ candidates = [m.group(1) for m in
1903
+ re.finditer(r"<code[^>]*>([\s\S]*?)</code>", reasoning, re.I)]
1904
+ candidates.reverse()
1905
+ tail = None
1906
+ for tail in re.finditer(r"<code[^>]*>([\s\S]*)$", reasoning, re.I):
1907
+ pass
1908
+ if tail:
1909
+ candidates.append(tail.group(1))
1910
+ code = None
1911
+ rejected = []
1912
+ for cand in candidates:
1913
+ cleaned = _clean(cand)
1914
+ ok, why = _looks_like_tool_code(cleaned)
1915
+ if ok:
1916
+ code = cleaned
1917
+ break
1918
+ if cleaned:
1919
+ rejected.append((cleaned, why))
1920
+ if code is None:
1921
+ if rejected:
1922
+ _preview, _why = rejected[0]
1923
+ _log(f"#113 rejected recovered block from reasoning — {_why} "
1924
+ f"({len(_preview)} chars): {_preview[:80]!r}; leaving content as-is")
1925
+ return content
1926
+ # Keep the visible Thought prose (strip any stray/unbalanced code tags), then
1927
+ # append the recovered block so smolagents parses a clean Thought → <code> pair.
1928
+ prose = re.sub(r"</?code[^>]*>", "", content or "", flags=re.I).strip()
1929
+ rebuilt = (prose + "\n" if prose else "") + "<code>\n" + code + "\n</code>"
1930
+ _log(f"#111 recovered <code> block from reasoning channel ({len(code)} chars)")
1931
+ return rebuilt
1932
+
1933
+
1693
1934
  def _strip_think(text: str) -> str:
1694
1935
  """Remove Qwen3-style <think>…</think> reasoning traces from a model reply.
1695
1936
 
@@ -4760,6 +5001,36 @@ def run_agent_chat(cfg):
4760
5001
  "Do NOT hand an unfinished task back to the user.")
4761
5002
  return True
4762
5003
 
5004
+ _CODE_TRIMMED_MARK = "\n…[code trimmed — already ran; result is in the Observation]"
5005
+
5006
+ def _trim_tool_call_args(step, cap):
5007
+ """Shrink a step's recorded tool-call ARGUMENTS. For a CodeAgent that value IS the
5008
+ full python code action, and smolagents renders it into its OWN 'Calling tools:'
5009
+ message on every later request (memory.py ActionStep.to_messages → ToolCall.dict).
5010
+
5011
+ This is the gap in the #63/#87 trims: they only ever touched model_output and
5012
+ observations, so the <code> they stripped out of the Thought came straight back
5013
+ here, verbatim, forever. On a file-writing step that duplicate is the single
5014
+ largest thing in the prompt (a create_file carries the whole file body twice).
5015
+ Gutting it is safe — the code ALREADY executed and its result is in the
5016
+ Observation; the leading chars we keep still name the tool and its first argument
5017
+ (the path), which is what a later step actually refers back to.
5018
+
5019
+ The ToolCall object, its id and its name are left intact — smolagents formats
5020
+ parse errors as f"Call id: {self.tool_calls[0].id}" — and only `str` arguments are
5021
+ touched, so a ToolCallingAgent's dict arguments are left alone. Returns chars
5022
+ saved; idempotent, since a re-trim recomputes the identical string and no-ops."""
5023
+ saved = 0
5024
+ for _tc in (getattr(step, "tool_calls", None) or []):
5025
+ _a = getattr(_tc, "arguments", None)
5026
+ if not isinstance(_a, str) or len(_a) <= cap:
5027
+ continue
5028
+ _new = _a[:cap].rstrip() + _CODE_TRIMMED_MARK
5029
+ if len(_new) < len(_a):
5030
+ _tc.arguments = _new
5031
+ saved += len(_a) - len(_new)
5032
+ return saved
5033
+
4763
5034
  def _aggressive_compact(steps):
4764
5035
  """User-approved deep compaction: keep only the LAST step in full; for every
4765
5036
  older step shrink the observation to a stub and the model_output to just its
@@ -4778,6 +5049,10 @@ def run_agent_chat(cfg):
4778
5049
  _head = re.split(r"<code[\s>]|```", _mo, maxsplit=1)[0].rstrip()
4779
5050
  _st.model_output = (_head[:200].rstrip() or _mo[:150].rstrip()) + "\n…[compacted]"
4780
5051
  saved += _b - len(_st.model_output)
5052
+ # The duplicated code action (see _trim_tool_call_args) — tighter than the
5053
+ # always-on pass, since a user-approved compaction is explicitly trading
5054
+ # recall for a smaller prompt.
5055
+ saved += _trim_tool_call_args(_st, 120)
4781
5056
  return saved
4782
5057
 
4783
5058
  def step_callback(step_log):
@@ -4836,6 +5111,15 @@ def run_agent_chat(cfg):
4836
5111
  _st.model_output = _new
4837
5112
  _trim_n += 1
4838
5113
  _trim_saved += _before - len(_new)
5114
+ # The SAME code, a second time: smolagents keeps the parsed code
5115
+ # action in step.tool_calls and renders it as its own message on
5116
+ # every request, so stripping <code> from model_output above saved
5117
+ # nothing on its own. Trim the duplicate too — see
5118
+ # _trim_tool_call_args for why this is safe.
5119
+ _tc_saved = _trim_tool_call_args(_st, 300)
5120
+ if _tc_saved:
5121
+ _trim_n += 1
5122
+ _trim_saved += _tc_saved
4839
5123
  if _trim_n:
4840
5124
  _log(f"context trim: shrank {_trim_n} old step field(s), "
4841
5125
  f"saved ~{_trim_saved} chars (#63/#87)")
@@ -4852,9 +5136,20 @@ def run_agent_chat(cfg):
4852
5136
  _tu = getattr(step_log, "token_usage", None)
4853
5137
  _in_tok = int(getattr(_tu, "input_tokens", 0) or 0) if _tu else 0
4854
5138
  if _in_tok <= 0:
4855
- _chars = sum(len(getattr(_s, "model_output", "") or "")
4856
- + len(getattr(_s, "observations", "") or "")
4857
- for _s in _steps)
5139
+ # Count every field that ActionStep.to_messages actually renders —
5140
+ # model_output, the tool-call arguments, and observations. The
5141
+ # tool_calls term used to be missing, which under-counted exactly
5142
+ # the part that grows fastest (the duplicated code action), so a
5143
+ # server that reports no usage would reach the ask late or never.
5144
+ _chars = 0
5145
+ for _s in _steps:
5146
+ _chars += len(getattr(_s, "model_output", "") or "")
5147
+ _chars += len(getattr(_s, "observations", "") or "")
5148
+ for _tc in (getattr(_s, "tool_calls", None) or []):
5149
+ _a = getattr(_tc, "arguments", None)
5150
+ # str = the code action; anything else renders as JSON,
5151
+ # so charge it a small flat cost rather than guessing.
5152
+ _chars += len(_a) if isinstance(_a, str) else 80
4858
5153
  _in_tok = _chars // 4 + 7000 # + fixed system/reference baseline
4859
5154
  if _in_tok >= _COMPACT_ASK_TOKENS and len(_steps) > 2:
4860
5155
  if _compact_state["decision"] != "always":
@@ -4922,7 +5217,8 @@ def run_agent_chat(cfg):
4922
5217
  # and any step memory it accumulates. completion_kwargs["messages"] here is the
4923
5218
  # literal messages array sent to /v1/chat/completions.
4924
5219
  class _LoggingModel(OpenAIServerModel):
4925
- def __init__(self, *a, stream_agent=False, save_full_response=False, **kw):
5220
+ def __init__(self, *a, stream_agent=False, save_full_response=False,
5221
+ openai_backend=False, **kw):
4926
5222
  # Running total of PROMPT (input) tokens sent to the CODE model across the
4927
5223
  # turn (task #83). Only this wrapper counts — the chat/brain helper calls
4928
5224
  # (_chat_completion) don't route through here, so this is "code model only".
@@ -4952,20 +5248,48 @@ def run_agent_chat(cfg):
4952
5248
  # per-turn call counter used as the stored `step`.
4953
5249
  self._save_full_response = bool(save_full_response)
4954
5250
  self._call_index = 0
5251
+ # Task #113: the coding backend is an explicit OpenAI-compatible endpoint
5252
+ # (Settings → kind=openai), which in practice means a cloud REASONING model.
5253
+ # Everything gated on this flag is OFF for Ollama and for Auto/MLX, so those
5254
+ # paths keep behaving exactly as they do today (the user switches back and
5255
+ # forth between Kimi and Ollama and the Ollama path must not move).
5256
+ self._openai_backend = bool(openai_backend)
5257
+ # Consecutive steps (openai backend only) that ended with NO runnable code
5258
+ # block at all — prose, empty output, a dangling tag, or a recovery we had to
5259
+ # reject. Distinct from _parse_fail_streak, which counts blocks that EXIST but
5260
+ # won't compile. Without this, a coder that never emits a parseable block just
5261
+ # loops to the step budget: the live trace burned 25k prompt tokens over three
5262
+ # steps and still had to be Ctrl+C'd by the user.
5263
+ self._no_code_streak = 0
4955
5264
  super().__init__(*a, **kw)
4956
5265
 
4957
5266
  def _generate_once(self, *args, **kwargs):
4958
5267
  """One model call. Streams + reassembles when _stream_agent, else the normal
4959
- non-streaming path. Streaming is a pure transport optimization: on ANY failure
4960
- it falls back to the non-streaming call, and it's skipped for tool-calling
4961
- mode (tools_to_call_from), whose tool_call deltas we don't reassemble."""
5268
+ non-streaming path. Streaming is a pure transport optimization: on a TRANSPORT
5269
+ failure it falls back to the non-streaming call, and it's skipped for
5270
+ tool-calling mode (tools_to_call_from), whose tool_call deltas we don't
5271
+ reassemble.
5272
+
5273
+ Exception (task #114): an OVERLOAD (429 / engine_overloaded / rate limit) is
5274
+ NOT a transport failure — the server refused the work. Re-sending the identical
5275
+ request unstreamed, instantly and with no backoff, is the worst possible answer:
5276
+ it can deepen the limit, and because a non-streamed call emits no first token it
5277
+ then sits silent behind the 600s client timeout (live: 167s of "Thinking…" until
5278
+ the user pressed Ctrl+C). Re-raise instead, so generate()'s retry loop applies a
5279
+ real backoff and tells the user what is going on."""
4962
5280
  tools_to_call_from = kwargs.get("tools_to_call_from")
4963
5281
  if not self._stream_agent or tools_to_call_from:
4964
5282
  return super().generate(*args, **kwargs)
4965
5283
  try:
4966
5284
  return self._streamed_generate(*args, **kwargs)
4967
5285
  except Exception as e: # noqa: BLE001
5286
+ if _is_backend_overloaded(e):
5287
+ _log(f"streamed generate refused ({_clip(str(e), 200)}) → NOT falling "
5288
+ "back to non-streaming; letting the retry loop back off (#114)")
5289
+ raise
4968
5290
  _log(f"streamed generate failed ({e}) → falling back to non-streaming")
5291
+ _emit({"type": "step",
5292
+ "text": "Streaming unavailable — retrying without streaming…"})
4969
5293
  return super().generate(*args, **kwargs)
4970
5294
 
4971
5295
  def _run_stream(self, completion_kwargs):
@@ -5105,51 +5429,10 @@ def run_agent_chat(cfg):
5105
5429
  return content, role, in_tok, out_tok, reasoning_len
5106
5430
 
5107
5431
  def _recover_code_from_channels(self, content, reasoning):
5108
- """Recover a <code>…</code> tool call that a reasoning model placed in the
5109
- REASONING channel instead of the visible message (task #111). No-op when the
5110
- visible content already has a usable code block, or when there is no reasoning
5111
- text (e.g. Ollama with reasoning_effort='none') — so the Ollama path is unchanged.
5112
-
5113
- Kimi K3 @ Moonshot splits its output two ways when it fails: (a) the full
5114
- <code>…</code> block lands in reasoning and only prose leaks to content; or
5115
- (b) the OPENER + code land in reasoning while the CLOSING </code> leaks into
5116
- content (seen live: content = 'Thought: …contains.</code>'). Handle both."""
5117
- _pair = r"<code[^>]*>[\s\S]*?</code>"
5118
- if re.search(_pair, content or "", re.I):
5119
- return content # content already has a usable block — never override it
5120
- reasoning = reasoning or ""
5121
- if not reasoning.strip():
5122
- return content # nothing to recover from (Ollama / non-reasoning model)
5123
- code = None
5124
- # (a) Prefer a COMPLETE block already inside reasoning (the model drafts the
5125
- # whole tool call there) — take the LAST one, ignoring any stray </code> in content.
5126
- last = None
5127
- for last in re.finditer(r"<code[^>]*>([\s\S]*?)</code>", reasoning, re.I):
5128
- pass
5129
- if last:
5130
- code = last.group(1)
5131
- else:
5132
- # (b) Opener + code in reasoning, closer leaked to content → take everything
5133
- # after the LAST opener in reasoning (its </code> is the one now in content).
5134
- m = None
5135
- for m in re.finditer(r"<code[^>]*>([\s\S]*)$", reasoning, re.I):
5136
- pass
5137
- if m:
5138
- code = m.group(1)
5139
- if code is None:
5140
- return content
5141
- # A leaked 'Thought:' line (or a stray </code>) can ride along after the code —
5142
- # cut it so only the executable snippet remains.
5143
- code = re.split(r"\n\s*Thought\s*:\s", code, maxsplit=1)[0]
5144
- code = re.sub(r"</?code[^>]*>", "", code, flags=re.I).strip()
5145
- if not code:
5146
- return content
5147
- # Keep the visible Thought prose (strip any stray/unbalanced code tags), then
5148
- # append the recovered block so smolagents parses a clean Thought → <code> pair.
5149
- prose = re.sub(r"</?code[^>]*>", "", content or "", flags=re.I).strip()
5150
- rebuilt = (prose + "\n" if prose else "") + "<code>\n" + code + "\n</code>"
5151
- _log(f"#111 recovered <code> block from reasoning channel ({len(code)} chars)")
5152
- return rebuilt
5432
+ """Thin wrapper over the module-level _recover_code_from_reasoning (kept as a
5433
+ method so the streaming path reads the same). The logic lives at module scope
5434
+ so it can be exercised directly by the offline replay checks."""
5435
+ return _recover_code_from_reasoning(content, reasoning)
5153
5436
 
5154
5437
  # Injected when a turn produced ONLY reasoning and no message content — a hard,
5155
5438
  # model-agnostic steer to emit the actionable code block instead of more analysis.
@@ -5193,13 +5476,16 @@ def run_agent_chat(cfg):
5193
5476
  # "regex pattern <code> was not found" error and burns the step before its
5194
5477
  # own retry recovers. A finished PROSE answer has NO code tags, so it never
5195
5478
  # trips this — it flows to the auto-wrap path in generate() untouched.
5479
+ # (3) PROSE-ONLY plan — the model spent the turn narrating what it is ABOUT
5480
+ # to do and emitted no code tag whatsoever (seen live with Kimi K3 step 1:
5481
+ # 221 chars of "I'll scaffold a NestJS project…", 3246 chars of reasoning,
5482
+ # nothing runnable). Trigger (2) missed this: the "</code>" that shows up
5483
+ # in the trace is appended by SMOLAGENTS after we return (its stop-sequence
5484
+ # handling), so the content WE see here carries no tag at all. Openai-only.
5485
+ # See _code_nudge_reason for the full decision (kept pure so it can be replayed).
5196
5486
  _pair_re = r"<code[^>]*>[\s\S]*?</code>"
5197
- _has_pair = re.search(_pair_re, content or "", re.I) is not None
5198
- _broken_code = (not _has_pair) and (
5199
- re.search(r"</?code[\s>/]", content or "", re.I) is not None
5200
- )
5201
- if not content.strip() or _broken_code:
5202
- why = "empty-content" if not content.strip() else "dangling code tag, no valid block"
5487
+ why = _code_nudge_reason(content, self._openai_backend)
5488
+ if why:
5203
5489
  _log(f"{why} turn (reasoning={reasoning_len} chars) → retrying once with a "
5204
5490
  "'code now' directive")
5205
5491
  _emit({"type": "step",
@@ -5335,18 +5621,45 @@ def run_agent_chat(cfg):
5335
5621
  "names (for Ollama use the tag, e.g. qwen3:14b)."
5336
5622
  ) from e
5337
5623
  last_err = e
5338
- gateway = _is_backend_unavailable(e)
5339
- if gateway and max_attempts < 6:
5624
+ # An OVERLOAD (429/rate limit, task #114) is its own class: the
5625
+ # server answered and told us to slow down, so it needs a PATIENT,
5626
+ # jittered backoff rather than the gateway policy's prompt re-send.
5627
+ # Checked first — _is_backend_unavailable already excludes it, but
5628
+ # the ordering keeps the intent obvious.
5629
+ overloaded = _is_backend_overloaded(e)
5630
+ gateway = (not overloaded) and _is_backend_unavailable(e)
5631
+ if (overloaded or gateway) and max_attempts < 6:
5340
5632
  max_attempts = 6 # stretch the budget for a flaky backend
5341
5633
  attempt += 1
5342
5634
  if attempt < max_attempts:
5343
5635
  # Longer, capped backoff for gateway errors (up to ~8s) so a
5344
- # cold/loading model has time to come back; short for the rest.
5345
- delay = min(8.0, 2.0 * attempt) if gateway else 1.5 * attempt
5636
+ # cold/loading model has time to come back; short for the rest;
5637
+ # patient + jittered for an overload. See _retry_delay.
5638
+ delay = _retry_delay(attempt, overloaded, gateway, e)
5346
5639
  kind = "gateway/backend unavailable" if gateway else "transient"
5640
+ if overloaded:
5641
+ kind = "backend overloaded (429/rate limit)"
5642
+ # SURFACE IT: without this the REPL keeps showing a playful
5643
+ # heartbeat word and the user has no idea the provider is
5644
+ # refusing work (live: 167s of "Thinking…" → Ctrl+C).
5645
+ _emit({"type": "step",
5646
+ "text": "Coding model is overloaded (429) — waiting "
5647
+ f"{delay:.0f}s, then retrying "
5648
+ f"(attempt {attempt + 1}/{max_attempts})…"})
5347
5649
  _log(f"generate {kind} error (attempt {attempt}/{max_attempts}): "
5348
5650
  f"{_clip(emsg, 160)} — retrying in {delay:.0f}s")
5349
5651
  time.sleep(delay)
5652
+ elif overloaded:
5653
+ # Out of attempts: end the turn with a cause the user can act on
5654
+ # instead of a bare stack trace (same spirit as #94's 502 copy).
5655
+ _log("backend overloaded after all attempts → failing honestly")
5656
+ raise _AgentBackendOverloaded(
5657
+ "The agent coding model backend "
5658
+ f"({_host_of(coding_base_url)}) is overloaded right now and "
5659
+ f"refused {max_attempts} attempts (HTTP 429). Nothing ran. "
5660
+ "Please try again in a minute, or switch the coding backend "
5661
+ "in Settings → Agent."
5662
+ ) from e
5350
5663
  if last_err is not None:
5351
5664
  raise last_err
5352
5665
  finally:
@@ -5509,12 +5822,92 @@ def run_agent_chat(cfg):
5509
5822
  f"scratchpad-like prose ({len(stripped)} chars, no tool call) "
5510
5823
  "→ NOT auto-wrapping; letting the step fail normally"
5511
5824
  )
5825
+ # Loop-breaker for reasoning coders (#113). _parse_fail_streak above only
5826
+ # counts blocks that EXIST but won't compile, so a coder that never emits a
5827
+ # usable block at all — prose plans, empty turns, a dangling tag, or a
5828
+ # recovery we rejected — never trips it and just burns the step budget
5829
+ # (live: 3 steps, ~25k prompt tokens, zero progress, user had to Ctrl+C).
5830
+ # Count consecutive no-block steps here and finish honestly instead. The
5831
+ # check is on the FINAL msg.content, so anything the repairs above rescued
5832
+ # (including the _parse_fail_streak fallback, which injects a compilable
5833
+ # final_answer) resets the streak. openai backend only — an Ollama coder
5834
+ # keeps looping exactly as long as it does today.
5835
+ if self._openai_backend:
5836
+ if _has_runnable_block(msg.content):
5837
+ self._no_code_streak = 0
5838
+ else:
5839
+ self._no_code_streak += 1
5840
+ _log("no runnable code block this step "
5841
+ f"(no-code streak {self._no_code_streak}/3, #113)")
5842
+ if self._no_code_streak >= 3:
5843
+ _log("no-code streak hit limit → ending the turn honestly "
5844
+ "instead of looping on a coder that won't emit code")
5845
+ _nocode_msg = (
5846
+ "I had to stop: the coding model kept replying with "
5847
+ "explanations instead of a runnable code block, so none of "
5848
+ "my steps could actually execute. This is usually a "
5849
+ "reasoning model putting its answer in the thinking channel. "
5850
+ "Try a non-reasoning coding model, or switch the coding "
5851
+ "backend back to Ollama in Settings, then run this again."
5852
+ )
5853
+ msg.content = ("<code>\nfinal_answer("
5854
+ + repr(_nocode_msg) + ")\n</code>")
5855
+ self._no_code_streak = 0
5856
+ self._parse_fail_streak = 0
5512
5857
  except Exception as e: # noqa: BLE001
5513
5858
  _log(f"content-normalize error: {e}")
5514
5859
  return msg
5515
5860
 
5861
+ # Replaces smolagents' stock code-parsing error for reasoning backends (#113).
5862
+ # The stock text spells out the delimiters twice ("the regex pattern <code>(.*?)
5863
+ # </code> was not found", "for instance: … <code> # Your python code here </code>")
5864
+ # and quotes the model's own broken output back at it. A reasoning model MIRRORS
5865
+ # those tokens: the live trace shows Kimi K3 answering the error with the literal
5866
+ # words "and closing with" wrapped in code tags — it was completing the sentence
5867
+ # from the prompt, not writing code. Deliberately carries NO literal delimiter (the
5868
+ # system prompt already shows the format with a worked example), so there is nothing
5869
+ # to echo. Applied only when kind=openai; Ollama keeps the stock message.
5870
+ _PARSE_ERROR_REPLACEMENT = (
5871
+ "Error: your last reply contained no runnable code block, so nothing executed "
5872
+ "and no observation was produced.\n"
5873
+ "Reply with ONE 'Thought:' line, then the code block using the exact opening and "
5874
+ "closing delimiters shown in the system prompt, with ONLY Python between them — "
5875
+ "no prose inside the block, and do not repeat these instructions back to me. "
5876
+ "Call exactly one tool, or call final_answer(...) if you are done."
5877
+ )
5878
+
5879
+ def _sanitize_parse_errors(self, msgs):
5880
+ """Swap smolagents' code-parsing error text for a delimiter-free directive
5881
+ (see _PARSE_ERROR_REPLACEMENT). No-op unless the backend is kind=openai, and
5882
+ no-op on every other kind of message — genuine SyntaxError feedback from a
5883
+ block that DID run is left intact, since that is real information."""
5884
+ if not self._openai_backend:
5885
+ return
5886
+ n = 0
5887
+ for m in msgs or []:
5888
+ if not isinstance(m, dict):
5889
+ continue # _prepare_completion_kwargs already returns plain dicts
5890
+ c = m.get("content")
5891
+ if isinstance(c, str):
5892
+ if "Error in code parsing" in c:
5893
+ m["content"] = self._PARSE_ERROR_REPLACEMENT
5894
+ n += 1
5895
+ elif isinstance(c, list):
5896
+ for part in c:
5897
+ if (isinstance(part, dict) and isinstance(part.get("text"), str)
5898
+ and "Error in code parsing" in part["text"]):
5899
+ part["text"] = self._PARSE_ERROR_REPLACEMENT
5900
+ n += 1
5901
+ if n:
5902
+ _log(f"#113 replaced {n} code-parsing error message(s) with a "
5903
+ "delimiter-free directive (openai backend)")
5904
+
5516
5905
  def _prepare_completion_kwargs(self, *args, **kwargs):
5517
5906
  ck = super()._prepare_completion_kwargs(*args, **kwargs)
5907
+ try:
5908
+ self._sanitize_parse_errors(ck.get("messages", []) or [])
5909
+ except Exception as e: # noqa: BLE001
5910
+ _log(f"#113 parse-error sanitize skipped: {e}")
5518
5911
  # Disable Qwen3 "thinking" for the agent loop. Live runs showed thinking-on
5519
5912
  # spends the whole completion budget on a <think> trace and returns
5520
5913
  # content=None (no code block) → parse-error spiral → WS timeout. The
@@ -5692,6 +6085,10 @@ def run_agent_chat(cfg):
5692
6085
  stream_agent=_stream_coder,
5693
6086
  # Task #107: persist each code-model response to Mongo when the user opted in.
5694
6087
  save_full_response=save_full_response,
6088
+ # Task #113: enables the reasoning-model repairs (prose-only nudge, parse-error
6089
+ # sanitizing, no-code breaker). Explicit kind=openai ONLY — Ollama and Auto/MLX
6090
+ # never take those branches.
6091
+ openai_backend=(coding_kind == "openai"),
5695
6092
  # Cap each HTTP attempt at 600s so a genuinely slow single call (a
5696
6093
  # remote M6000/Vulkan box doing a cold ~4-5min prompt-eval) can finish
5697
6094
  # on the first try, while a truly hung request still fails into OUR
@@ -6756,6 +7153,29 @@ def run_agent_chat(cfg):
6756
7153
  # failure: it hides the outage and, being persisted to history, misleads the next
6757
7154
  # turn. Emit a clear, standalone "backend is down, retry" and stop — the checkpoint
6758
7155
  # is kept (not cleared) so a later "continue" resumes once the box is back.
7156
+ # Coding-model OVERLOAD (429/rate limit, task #114): same reasoning as the outage
7157
+ # branch below — the tools never ran, so degrading to _plain_reply would invent a
7158
+ # confident how-to for work that didn't happen. Reported separately because the
7159
+ # cause and the advice differ: the server is UP, it's just saturated or we're over
7160
+ # quota, so "try again shortly" is the honest fix. Checked BEFORE the outage branch
7161
+ # (_is_backend_unavailable deliberately excludes overloads).
7162
+ if _is_backend_overloaded(e):
7163
+ _log(f"agent error (coding-model backend OVERLOADED, NOT degrading): {_clip(str(e), 160)}")
7164
+ _ov = None
7165
+ _cur, _depth = e, 0
7166
+ while _cur is not None and _depth < 6:
7167
+ if isinstance(_cur, _AgentBackendOverloaded):
7168
+ _ov = _cur
7169
+ break
7170
+ _cur, _depth = (getattr(_cur, "__cause__", None)
7171
+ or getattr(_cur, "__context__", None)), _depth + 1
7172
+ _emit({"type": "final", "text": "⚠️ " + (str(_ov) if _ov else (
7173
+ "The coding model backend is overloaded right now (HTTP 429 — it asked me "
7174
+ "to slow down), so I couldn't run the tools to do this. The server is up, "
7175
+ "it's just saturated or over quota. Please try again in a minute, or switch "
7176
+ "the coding backend in Settings → Agent. (I didn't make any changes.)"
7177
+ ))})
7178
+ return
6759
7179
  if _is_backend_unavailable(e):
6760
7180
  _log(f"agent error (coding-model backend unavailable, NOT degrading): {_clip(str(e), 160)}")
6761
7181
  _emit({"type": "final", "text": (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.318",
3
+ "version": "1.0.322",
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",