@tiens.nguyen/gonext-local-worker 1.0.317 → 1.0.320

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 +359 -69
  2. package/package.json +1 -1
@@ -1690,6 +1690,146 @@ def _repair_code_block_strings(text: str) -> str:
1690
1690
  return text[:open_end] + sanitized + text[last:]
1691
1691
 
1692
1692
 
1693
+ def _looks_like_tool_code(code: str):
1694
+ """Is `code` plausibly a runnable tool call, i.e. worth substituting into the step?
1695
+
1696
+ Returns (ok, why_not). Used ONLY to vet a block recovered from a reasoning channel
1697
+ (task #113) — never to judge code the model put in the visible message, which keeps
1698
+ the normal path (and Ollama) byte-for-byte unchanged.
1699
+
1700
+ Why this gate exists: a reasoning model narrates the FORMAT rules to itself, so the
1701
+ text between a <code>…</code> pair in its reasoning is often English, not python.
1702
+ Seen live with Kimi K3, recovering the 16 chars "and closing with" (it was echoing
1703
+ the system prompt's "OPENS with <code> and CLOSES with </code>") — we wrapped that
1704
+ prose in tags and executed it, turning a clean parse error into a SyntaxError and a
1705
+ poisoned history. Substituting un-vetted prose is strictly worse than not recovering.
1706
+
1707
+ Two checks, both cheap:
1708
+ 1. it must COMPILE as python (kills prose, which is the actual failure mode);
1709
+ 2. it must CALL something (`name(`) — every legitimate CodeAgent step calls a tool
1710
+ or final_answer, while a bare word like "yes" happens to compile as a Name."""
1711
+ code = (code or "").strip()
1712
+ if not code:
1713
+ return False, "empty"
1714
+ try:
1715
+ compile(code, "<gonext-recovered>", "exec")
1716
+ except SyntaxError as e:
1717
+ return False, f"not python ({(getattr(e, 'msg', '') or 'syntax error')})"
1718
+ except Exception as e: # noqa: BLE001
1719
+ return False, f"not compilable ({e})"
1720
+ if not re.search(r"[A-Za-z_][A-Za-z0-9_]*\s*\(", code):
1721
+ return False, "no function call"
1722
+ return True, ""
1723
+
1724
+
1725
+ def _has_runnable_block(text) -> bool:
1726
+ """True if `text` contains a <code>…</code> block whose python actually compiles —
1727
+ i.e. this step produced something that can run. Used by the #113 no-code loop-breaker
1728
+ (openai backend only) to tell "the step worked" from "the model wrote prose again"."""
1729
+ if not isinstance(text, str) or not text:
1730
+ return False
1731
+ blk = re.search(r"<code[^>]*>([\s\S]*?)</code>", text, re.I)
1732
+ if not blk:
1733
+ return False
1734
+ try:
1735
+ compile(blk.group(1), "<gonext-code>", "exec")
1736
+ return True
1737
+ except Exception: # noqa: BLE001
1738
+ return False
1739
+
1740
+
1741
+ def _code_nudge_reason(content, openai_backend: bool):
1742
+ """Why (if at all) this streamed turn should be re-prompted once with the CODE-NOW
1743
+ directive — or None to accept it as-is. Pure decision so the three triggers can be
1744
+ replayed offline against real traces (task #113).
1745
+
1746
+ 1. empty-content — the whole turn went to the reasoning channel, no message.
1747
+ 2. dangling tag — a stray <code>/</code> with no valid pair.
1748
+ 3. prose-only plan — narration of what it is ABOUT to do, no tag at all. This one
1749
+ is openai-only and additionally requires _looks_like_scratchpad, so a FINISHED
1750
+ prose answer is never nudged into inventing another tool call. It exists because
1751
+ the "</code>" visible in a smolagents trace is appended by smolagents AFTER the
1752
+ model returns, so trigger 2 cannot see this shape.
1753
+
1754
+ Triggers 1 and 2 keep their existing backend-agnostic behavior — the Ollama path is
1755
+ unchanged, since trigger 3 is the only new one and it is gated off there."""
1756
+ text = (content or "").strip()
1757
+ if not text:
1758
+ return "empty-content"
1759
+ if re.search(r"<code[^>]*>[\s\S]*?</code>", content, re.I):
1760
+ return None # a real block — nothing to nudge
1761
+ if re.search(r"</?code[\s>/]", content, re.I):
1762
+ return "dangling code tag, no valid block"
1763
+ if openai_backend and _looks_like_scratchpad(text):
1764
+ return "prose-only plan, no code block"
1765
+ return None
1766
+
1767
+
1768
+ def _recover_code_from_reasoning(content, reasoning):
1769
+ """Recover a <code>…</code> tool call that a reasoning model placed in the
1770
+ REASONING channel instead of the visible message (task #111). No-op when the
1771
+ visible content already has a usable code block, or when there is no reasoning
1772
+ text (e.g. Ollama with reasoning_effort='none') — so the Ollama path is unchanged.
1773
+
1774
+ Kimi K3 @ Moonshot splits its output two ways when it fails: (a) the full
1775
+ <code>…</code> block lands in reasoning and only prose leaks to content; or
1776
+ (b) the OPENER + code land in reasoning while the CLOSING </code> leaks into
1777
+ content (seen live: content = 'Thought: …contains.</code>'). Handle both.
1778
+
1779
+ Every candidate is VETTED with _looks_like_tool_code before it is used (task
1780
+ #113): the same reasoning stream also contains the model narrating the format
1781
+ rules to itself, so a <code>…</code> pair found there can hold plain English.
1782
+ Candidates are tried NEWEST-first and the first runnable one wins; if none is
1783
+ runnable we return `content` untouched and let the step fail normally, which
1784
+ is what happened before #111 existed."""
1785
+ _pair = r"<code[^>]*>[\s\S]*?</code>"
1786
+ if re.search(_pair, content or "", re.I):
1787
+ return content # content already has a usable block — never override it
1788
+ reasoning = reasoning or ""
1789
+ if not reasoning.strip():
1790
+ return content # nothing to recover from (Ollama / non-reasoning model)
1791
+
1792
+ def _clean(raw):
1793
+ # A leaked 'Thought:' line (or a stray </code>) can ride along after the
1794
+ # code — cut it so only the executable snippet remains.
1795
+ out = re.split(r"\n\s*Thought\s*:\s", raw, maxsplit=1)[0]
1796
+ return re.sub(r"</?code[^>]*>", "", out, flags=re.I).strip()
1797
+
1798
+ # (a) COMPLETE blocks inside reasoning (the model drafts the whole tool call
1799
+ # there), newest first. (b) As a last resort, the opener + code in reasoning
1800
+ # with the closer leaked into content → everything after the LAST opener.
1801
+ candidates = [m.group(1) for m in
1802
+ re.finditer(r"<code[^>]*>([\s\S]*?)</code>", reasoning, re.I)]
1803
+ candidates.reverse()
1804
+ tail = None
1805
+ for tail in re.finditer(r"<code[^>]*>([\s\S]*)$", reasoning, re.I):
1806
+ pass
1807
+ if tail:
1808
+ candidates.append(tail.group(1))
1809
+ code = None
1810
+ rejected = []
1811
+ for cand in candidates:
1812
+ cleaned = _clean(cand)
1813
+ ok, why = _looks_like_tool_code(cleaned)
1814
+ if ok:
1815
+ code = cleaned
1816
+ break
1817
+ if cleaned:
1818
+ rejected.append((cleaned, why))
1819
+ if code is None:
1820
+ if rejected:
1821
+ _preview, _why = rejected[0]
1822
+ _log(f"#113 rejected recovered block from reasoning — {_why} "
1823
+ f"({len(_preview)} chars): {_preview[:80]!r}; leaving content as-is")
1824
+ return content
1825
+ # Keep the visible Thought prose (strip any stray/unbalanced code tags), then
1826
+ # append the recovered block so smolagents parses a clean Thought → <code> pair.
1827
+ prose = re.sub(r"</?code[^>]*>", "", content or "", flags=re.I).strip()
1828
+ rebuilt = (prose + "\n" if prose else "") + "<code>\n" + code + "\n</code>"
1829
+ _log(f"#111 recovered <code> block from reasoning channel ({len(code)} chars)")
1830
+ return rebuilt
1831
+
1832
+
1693
1833
  def _strip_think(text: str) -> str:
1694
1834
  """Remove Qwen3-style <think>…</think> reasoning traces from a model reply.
1695
1835
 
@@ -3177,15 +3317,32 @@ def run_agent_chat(cfg):
3177
3317
  # Multi-step ReAct loop (thinking agent): the agent may take several
3178
3318
  # Thought → tool → Observation steps and then call final_answer() itself. This
3179
3319
  # replaced the old strict single-shot (max_steps=1) once a stronger coding model
3180
- # (Qwen3-14B class) made real multi-step reasoning reliable. Default budget 8,
3181
- # overridable via cfg.maxSteps. The provide_final_answer override below is now only
3182
- # the exhaustion fallback (loop ends without final_answer).
3320
+ # (Qwen3-14B class) made real multi-step reasoning reliable. The default budget is
3321
+ # BACKEND-AWARE (a cloud OpenAI-compatible coder is stronger and cheaper-per-step than
3322
+ # a small local model, so it gets more room; a local Ollama coder gets a middle budget;
3323
+ # local MLX keeps the original tight default). Overridable via cfg.maxSteps.
3324
+ # The provide_final_answer override below is only the exhaustion fallback.
3325
+ #
3326
+ # Resolve the effective backend the SAME way as the model setup further down
3327
+ # (explicit kind wins; Auto sniffs the URL) so the budgets match the coder that runs.
3328
+ if coding_kind == "openai":
3329
+ _budget_is_ollama = False
3330
+ elif coding_kind == "ollama":
3331
+ _budget_is_ollama = True
3332
+ else:
3333
+ _budget_is_ollama = _is_ollama_server(coding_base_url)
3334
+ if coding_kind == "openai":
3335
+ _default_budget, _ws_budget = 20, 40 # OpenAI-compatible coder
3336
+ elif _budget_is_ollama:
3337
+ _default_budget, _ws_budget = 10, 20 # Ollama coder
3338
+ else:
3339
+ _default_budget, _ws_budget = 5, 12 # local MLX / Auto — original defaults
3183
3340
  try:
3184
- max_steps = int(cfg.get("maxSteps") or 5)
3341
+ max_steps = int(cfg.get("maxSteps") or _default_budget)
3185
3342
  except (TypeError, ValueError):
3186
- max_steps = 8
3343
+ max_steps = _default_budget
3187
3344
  if max_steps < 1:
3188
- max_steps = 8
3345
+ max_steps = _default_budget
3189
3346
 
3190
3347
  # Max web_search + fetch_url calls per task (user-configurable in web Settings →
3191
3348
  # Agent). Default 10; past it the retrieval tools refuse and steer to finish.
@@ -3392,11 +3549,12 @@ def run_agent_chat(cfg):
3392
3549
  _log(f"active workspace ignored (error resolving '{_aw_raw}'): {_e}")
3393
3550
  if _WS_AVAILABLE:
3394
3551
  _log(f"workspaces: {[(w['name'], w['path'], w['allowRun']) for w in _WS_ROOTS]}")
3395
- # Coding tasks are edit→test→fix loops — 5 steps is too tight. Bump the default
3396
- # budget when workspaces are registered, unless the payload set one explicitly.
3397
- if not cfg.get("maxSteps") and max_steps < 12:
3398
- max_steps = 12
3399
- _log("workspace registered → step budget raised to 12 (no explicit maxSteps)")
3552
+ # Coding tasks are edit→test→fix loops — the normal budget is too tight. Bump to
3553
+ # the backend-aware workspace budget (openai 40 / ollama 20 / MLX 12) when
3554
+ # workspaces are registered, unless the payload set one explicitly.
3555
+ if not cfg.get("maxSteps") and max_steps < _ws_budget:
3556
+ max_steps = _ws_budget
3557
+ _log(f"workspace registered → step budget raised to {_ws_budget} (no explicit maxSteps)")
3400
3558
  # Auto-test adds a verify → fix → re-test cycle on top of the work itself, so give
3401
3559
  # it more room (unless the user pinned a budget). Kept modest so a stuck test loop
3402
3560
  # still terminates rather than burning a huge budget.
@@ -4742,6 +4900,36 @@ def run_agent_chat(cfg):
4742
4900
  "Do NOT hand an unfinished task back to the user.")
4743
4901
  return True
4744
4902
 
4903
+ _CODE_TRIMMED_MARK = "\n…[code trimmed — already ran; result is in the Observation]"
4904
+
4905
+ def _trim_tool_call_args(step, cap):
4906
+ """Shrink a step's recorded tool-call ARGUMENTS. For a CodeAgent that value IS the
4907
+ full python code action, and smolagents renders it into its OWN 'Calling tools:'
4908
+ message on every later request (memory.py ActionStep.to_messages → ToolCall.dict).
4909
+
4910
+ This is the gap in the #63/#87 trims: they only ever touched model_output and
4911
+ observations, so the <code> they stripped out of the Thought came straight back
4912
+ here, verbatim, forever. On a file-writing step that duplicate is the single
4913
+ largest thing in the prompt (a create_file carries the whole file body twice).
4914
+ Gutting it is safe — the code ALREADY executed and its result is in the
4915
+ Observation; the leading chars we keep still name the tool and its first argument
4916
+ (the path), which is what a later step actually refers back to.
4917
+
4918
+ The ToolCall object, its id and its name are left intact — smolagents formats
4919
+ parse errors as f"Call id: {self.tool_calls[0].id}" — and only `str` arguments are
4920
+ touched, so a ToolCallingAgent's dict arguments are left alone. Returns chars
4921
+ saved; idempotent, since a re-trim recomputes the identical string and no-ops."""
4922
+ saved = 0
4923
+ for _tc in (getattr(step, "tool_calls", None) or []):
4924
+ _a = getattr(_tc, "arguments", None)
4925
+ if not isinstance(_a, str) or len(_a) <= cap:
4926
+ continue
4927
+ _new = _a[:cap].rstrip() + _CODE_TRIMMED_MARK
4928
+ if len(_new) < len(_a):
4929
+ _tc.arguments = _new
4930
+ saved += len(_a) - len(_new)
4931
+ return saved
4932
+
4745
4933
  def _aggressive_compact(steps):
4746
4934
  """User-approved deep compaction: keep only the LAST step in full; for every
4747
4935
  older step shrink the observation to a stub and the model_output to just its
@@ -4760,6 +4948,10 @@ def run_agent_chat(cfg):
4760
4948
  _head = re.split(r"<code[\s>]|```", _mo, maxsplit=1)[0].rstrip()
4761
4949
  _st.model_output = (_head[:200].rstrip() or _mo[:150].rstrip()) + "\n…[compacted]"
4762
4950
  saved += _b - len(_st.model_output)
4951
+ # The duplicated code action (see _trim_tool_call_args) — tighter than the
4952
+ # always-on pass, since a user-approved compaction is explicitly trading
4953
+ # recall for a smaller prompt.
4954
+ saved += _trim_tool_call_args(_st, 120)
4763
4955
  return saved
4764
4956
 
4765
4957
  def step_callback(step_log):
@@ -4818,6 +5010,15 @@ def run_agent_chat(cfg):
4818
5010
  _st.model_output = _new
4819
5011
  _trim_n += 1
4820
5012
  _trim_saved += _before - len(_new)
5013
+ # The SAME code, a second time: smolagents keeps the parsed code
5014
+ # action in step.tool_calls and renders it as its own message on
5015
+ # every request, so stripping <code> from model_output above saved
5016
+ # nothing on its own. Trim the duplicate too — see
5017
+ # _trim_tool_call_args for why this is safe.
5018
+ _tc_saved = _trim_tool_call_args(_st, 300)
5019
+ if _tc_saved:
5020
+ _trim_n += 1
5021
+ _trim_saved += _tc_saved
4821
5022
  if _trim_n:
4822
5023
  _log(f"context trim: shrank {_trim_n} old step field(s), "
4823
5024
  f"saved ~{_trim_saved} chars (#63/#87)")
@@ -4834,9 +5035,20 @@ def run_agent_chat(cfg):
4834
5035
  _tu = getattr(step_log, "token_usage", None)
4835
5036
  _in_tok = int(getattr(_tu, "input_tokens", 0) or 0) if _tu else 0
4836
5037
  if _in_tok <= 0:
4837
- _chars = sum(len(getattr(_s, "model_output", "") or "")
4838
- + len(getattr(_s, "observations", "") or "")
4839
- for _s in _steps)
5038
+ # Count every field that ActionStep.to_messages actually renders —
5039
+ # model_output, the tool-call arguments, and observations. The
5040
+ # tool_calls term used to be missing, which under-counted exactly
5041
+ # the part that grows fastest (the duplicated code action), so a
5042
+ # server that reports no usage would reach the ask late or never.
5043
+ _chars = 0
5044
+ for _s in _steps:
5045
+ _chars += len(getattr(_s, "model_output", "") or "")
5046
+ _chars += len(getattr(_s, "observations", "") or "")
5047
+ for _tc in (getattr(_s, "tool_calls", None) or []):
5048
+ _a = getattr(_tc, "arguments", None)
5049
+ # str = the code action; anything else renders as JSON,
5050
+ # so charge it a small flat cost rather than guessing.
5051
+ _chars += len(_a) if isinstance(_a, str) else 80
4840
5052
  _in_tok = _chars // 4 + 7000 # + fixed system/reference baseline
4841
5053
  if _in_tok >= _COMPACT_ASK_TOKENS and len(_steps) > 2:
4842
5054
  if _compact_state["decision"] != "always":
@@ -4904,7 +5116,8 @@ def run_agent_chat(cfg):
4904
5116
  # and any step memory it accumulates. completion_kwargs["messages"] here is the
4905
5117
  # literal messages array sent to /v1/chat/completions.
4906
5118
  class _LoggingModel(OpenAIServerModel):
4907
- def __init__(self, *a, stream_agent=False, save_full_response=False, **kw):
5119
+ def __init__(self, *a, stream_agent=False, save_full_response=False,
5120
+ openai_backend=False, **kw):
4908
5121
  # Running total of PROMPT (input) tokens sent to the CODE model across the
4909
5122
  # turn (task #83). Only this wrapper counts — the chat/brain helper calls
4910
5123
  # (_chat_completion) don't route through here, so this is "code model only".
@@ -4934,6 +5147,19 @@ def run_agent_chat(cfg):
4934
5147
  # per-turn call counter used as the stored `step`.
4935
5148
  self._save_full_response = bool(save_full_response)
4936
5149
  self._call_index = 0
5150
+ # Task #113: the coding backend is an explicit OpenAI-compatible endpoint
5151
+ # (Settings → kind=openai), which in practice means a cloud REASONING model.
5152
+ # Everything gated on this flag is OFF for Ollama and for Auto/MLX, so those
5153
+ # paths keep behaving exactly as they do today (the user switches back and
5154
+ # forth between Kimi and Ollama and the Ollama path must not move).
5155
+ self._openai_backend = bool(openai_backend)
5156
+ # Consecutive steps (openai backend only) that ended with NO runnable code
5157
+ # block at all — prose, empty output, a dangling tag, or a recovery we had to
5158
+ # reject. Distinct from _parse_fail_streak, which counts blocks that EXIST but
5159
+ # won't compile. Without this, a coder that never emits a parseable block just
5160
+ # loops to the step budget: the live trace burned 25k prompt tokens over three
5161
+ # steps and still had to be Ctrl+C'd by the user.
5162
+ self._no_code_streak = 0
4937
5163
  super().__init__(*a, **kw)
4938
5164
 
4939
5165
  def _generate_once(self, *args, **kwargs):
@@ -5087,51 +5313,10 @@ def run_agent_chat(cfg):
5087
5313
  return content, role, in_tok, out_tok, reasoning_len
5088
5314
 
5089
5315
  def _recover_code_from_channels(self, content, reasoning):
5090
- """Recover a <code>…</code> tool call that a reasoning model placed in the
5091
- REASONING channel instead of the visible message (task #111). No-op when the
5092
- visible content already has a usable code block, or when there is no reasoning
5093
- text (e.g. Ollama with reasoning_effort='none') — so the Ollama path is unchanged.
5094
-
5095
- Kimi K3 @ Moonshot splits its output two ways when it fails: (a) the full
5096
- <code>…</code> block lands in reasoning and only prose leaks to content; or
5097
- (b) the OPENER + code land in reasoning while the CLOSING </code> leaks into
5098
- content (seen live: content = 'Thought: …contains.</code>'). Handle both."""
5099
- _pair = r"<code[^>]*>[\s\S]*?</code>"
5100
- if re.search(_pair, content or "", re.I):
5101
- return content # content already has a usable block — never override it
5102
- reasoning = reasoning or ""
5103
- if not reasoning.strip():
5104
- return content # nothing to recover from (Ollama / non-reasoning model)
5105
- code = None
5106
- # (a) Prefer a COMPLETE block already inside reasoning (the model drafts the
5107
- # whole tool call there) — take the LAST one, ignoring any stray </code> in content.
5108
- last = None
5109
- for last in re.finditer(r"<code[^>]*>([\s\S]*?)</code>", reasoning, re.I):
5110
- pass
5111
- if last:
5112
- code = last.group(1)
5113
- else:
5114
- # (b) Opener + code in reasoning, closer leaked to content → take everything
5115
- # after the LAST opener in reasoning (its </code> is the one now in content).
5116
- m = None
5117
- for m in re.finditer(r"<code[^>]*>([\s\S]*)$", reasoning, re.I):
5118
- pass
5119
- if m:
5120
- code = m.group(1)
5121
- if code is None:
5122
- return content
5123
- # A leaked 'Thought:' line (or a stray </code>) can ride along after the code —
5124
- # cut it so only the executable snippet remains.
5125
- code = re.split(r"\n\s*Thought\s*:\s", code, maxsplit=1)[0]
5126
- code = re.sub(r"</?code[^>]*>", "", code, flags=re.I).strip()
5127
- if not code:
5128
- return content
5129
- # Keep the visible Thought prose (strip any stray/unbalanced code tags), then
5130
- # append the recovered block so smolagents parses a clean Thought → <code> pair.
5131
- prose = re.sub(r"</?code[^>]*>", "", content or "", flags=re.I).strip()
5132
- rebuilt = (prose + "\n" if prose else "") + "<code>\n" + code + "\n</code>"
5133
- _log(f"#111 recovered <code> block from reasoning channel ({len(code)} chars)")
5134
- return rebuilt
5316
+ """Thin wrapper over the module-level _recover_code_from_reasoning (kept as a
5317
+ method so the streaming path reads the same). The logic lives at module scope
5318
+ so it can be exercised directly by the offline replay checks."""
5319
+ return _recover_code_from_reasoning(content, reasoning)
5135
5320
 
5136
5321
  # Injected when a turn produced ONLY reasoning and no message content — a hard,
5137
5322
  # model-agnostic steer to emit the actionable code block instead of more analysis.
@@ -5163,14 +5348,30 @@ def run_agent_chat(cfg):
5163
5348
  # Ollama honors it). Harmless if the server ignores it — usage stays 0.
5164
5349
  completion_kwargs["stream_options"] = {"include_usage": True}
5165
5350
  content, role, in_tok, out_tok, reasoning_len = self._run_stream(completion_kwargs)
5166
- # Empty-turn retry: reasoning models (gemma4 on Ollama) sometimes spend the
5167
- # WHOLE turn in the reasoning channel and emit no content → smolagents parses an
5168
- # empty tool call and WASTES the step (seen live: 2 of 5 steps burned this way).
5169
5351
  # Retry ONCE with a hard "emit the code block now" directive appended to the
5170
5352
  # already-prepared API messages (plain dicts — no smolagents format guessing).
5171
- if not content.strip():
5172
- _log(f"empty-content turn (reasoning={reasoning_len} chars, no message) "
5173
- "→ retrying once with a 'code now' directive")
5353
+ # Two triggers:
5354
+ # (1) EMPTY content reasoning models (gemma4 on Ollama) sometimes spend the
5355
+ # WHOLE turn in the reasoning channel and emit no message.
5356
+ # (2) BROKEN code attempt — content has a dangling <code>/</code> tag but NO
5357
+ # valid pair, and #111 recovery found nothing in reasoning either (seen
5358
+ # live with Kimi K3: content = "Thought: …</code>", reasoning is plain
5359
+ # planning prose with no code). Without this, smolagents shows the ugly
5360
+ # "regex pattern <code> was not found" error and burns the step before its
5361
+ # own retry recovers. A finished PROSE answer has NO code tags, so it never
5362
+ # trips this — it flows to the auto-wrap path in generate() untouched.
5363
+ # (3) PROSE-ONLY plan — the model spent the turn narrating what it is ABOUT
5364
+ # to do and emitted no code tag whatsoever (seen live with Kimi K3 step 1:
5365
+ # 221 chars of "I'll scaffold a NestJS project…", 3246 chars of reasoning,
5366
+ # nothing runnable). Trigger (2) missed this: the "</code>" that shows up
5367
+ # in the trace is appended by SMOLAGENTS after we return (its stop-sequence
5368
+ # handling), so the content WE see here carries no tag at all. Openai-only.
5369
+ # See _code_nudge_reason for the full decision (kept pure so it can be replayed).
5370
+ _pair_re = r"<code[^>]*>[\s\S]*?</code>"
5371
+ why = _code_nudge_reason(content, self._openai_backend)
5372
+ if why:
5373
+ _log(f"{why} turn (reasoning={reasoning_len} chars) → retrying once with a "
5374
+ "'code now' directive")
5174
5375
  _emit({"type": "step",
5175
5376
  "text": "Model produced only analysis — nudging it to write the action…"})
5176
5377
  retry_kwargs = dict(completion_kwargs)
@@ -5178,10 +5379,15 @@ def run_agent_chat(cfg):
5178
5379
  {"role": "user", "content": self._CODE_NOW_DIRECTIVE}
5179
5380
  ]
5180
5381
  r_content, r_role, r_in, r_out, _ = self._run_stream(retry_kwargs)
5181
- if r_content.strip():
5382
+ r_has_pair = re.search(_pair_re, r_content or "", re.I) is not None
5383
+ # Use the retry when it yields a real code block, or (empty original) any
5384
+ # content. If the retry is still broken and we HAD a Thought, keep the
5385
+ # original and let smolagents' own retry take it from here — no regression.
5386
+ if r_has_pair or (not content.strip() and r_content.strip()):
5182
5387
  content, role = r_content, r_role
5183
- # Count both calls' output so token accounting isn't understated.
5184
- in_tok, out_tok = (r_in or in_tok), out_tok + r_out
5388
+ in_tok = r_in or in_tok
5389
+ # Count the retry call's output either way — it was generated/billed (#112).
5390
+ out_tok += r_out
5185
5391
  if stop_sequences and not self.supports_stop_parameter:
5186
5392
  from smolagents.models import remove_content_after_stop_sequences
5187
5393
  content = remove_content_after_stop_sequences(content, stop_sequences)
@@ -5473,12 +5679,92 @@ def run_agent_chat(cfg):
5473
5679
  f"scratchpad-like prose ({len(stripped)} chars, no tool call) "
5474
5680
  "→ NOT auto-wrapping; letting the step fail normally"
5475
5681
  )
5682
+ # Loop-breaker for reasoning coders (#113). _parse_fail_streak above only
5683
+ # counts blocks that EXIST but won't compile, so a coder that never emits a
5684
+ # usable block at all — prose plans, empty turns, a dangling tag, or a
5685
+ # recovery we rejected — never trips it and just burns the step budget
5686
+ # (live: 3 steps, ~25k prompt tokens, zero progress, user had to Ctrl+C).
5687
+ # Count consecutive no-block steps here and finish honestly instead. The
5688
+ # check is on the FINAL msg.content, so anything the repairs above rescued
5689
+ # (including the _parse_fail_streak fallback, which injects a compilable
5690
+ # final_answer) resets the streak. openai backend only — an Ollama coder
5691
+ # keeps looping exactly as long as it does today.
5692
+ if self._openai_backend:
5693
+ if _has_runnable_block(msg.content):
5694
+ self._no_code_streak = 0
5695
+ else:
5696
+ self._no_code_streak += 1
5697
+ _log("no runnable code block this step "
5698
+ f"(no-code streak {self._no_code_streak}/3, #113)")
5699
+ if self._no_code_streak >= 3:
5700
+ _log("no-code streak hit limit → ending the turn honestly "
5701
+ "instead of looping on a coder that won't emit code")
5702
+ _nocode_msg = (
5703
+ "I had to stop: the coding model kept replying with "
5704
+ "explanations instead of a runnable code block, so none of "
5705
+ "my steps could actually execute. This is usually a "
5706
+ "reasoning model putting its answer in the thinking channel. "
5707
+ "Try a non-reasoning coding model, or switch the coding "
5708
+ "backend back to Ollama in Settings, then run this again."
5709
+ )
5710
+ msg.content = ("<code>\nfinal_answer("
5711
+ + repr(_nocode_msg) + ")\n</code>")
5712
+ self._no_code_streak = 0
5713
+ self._parse_fail_streak = 0
5476
5714
  except Exception as e: # noqa: BLE001
5477
5715
  _log(f"content-normalize error: {e}")
5478
5716
  return msg
5479
5717
 
5718
+ # Replaces smolagents' stock code-parsing error for reasoning backends (#113).
5719
+ # The stock text spells out the delimiters twice ("the regex pattern <code>(.*?)
5720
+ # </code> was not found", "for instance: … <code> # Your python code here </code>")
5721
+ # and quotes the model's own broken output back at it. A reasoning model MIRRORS
5722
+ # those tokens: the live trace shows Kimi K3 answering the error with the literal
5723
+ # words "and closing with" wrapped in code tags — it was completing the sentence
5724
+ # from the prompt, not writing code. Deliberately carries NO literal delimiter (the
5725
+ # system prompt already shows the format with a worked example), so there is nothing
5726
+ # to echo. Applied only when kind=openai; Ollama keeps the stock message.
5727
+ _PARSE_ERROR_REPLACEMENT = (
5728
+ "Error: your last reply contained no runnable code block, so nothing executed "
5729
+ "and no observation was produced.\n"
5730
+ "Reply with ONE 'Thought:' line, then the code block using the exact opening and "
5731
+ "closing delimiters shown in the system prompt, with ONLY Python between them — "
5732
+ "no prose inside the block, and do not repeat these instructions back to me. "
5733
+ "Call exactly one tool, or call final_answer(...) if you are done."
5734
+ )
5735
+
5736
+ def _sanitize_parse_errors(self, msgs):
5737
+ """Swap smolagents' code-parsing error text for a delimiter-free directive
5738
+ (see _PARSE_ERROR_REPLACEMENT). No-op unless the backend is kind=openai, and
5739
+ no-op on every other kind of message — genuine SyntaxError feedback from a
5740
+ block that DID run is left intact, since that is real information."""
5741
+ if not self._openai_backend:
5742
+ return
5743
+ n = 0
5744
+ for m in msgs or []:
5745
+ if not isinstance(m, dict):
5746
+ continue # _prepare_completion_kwargs already returns plain dicts
5747
+ c = m.get("content")
5748
+ if isinstance(c, str):
5749
+ if "Error in code parsing" in c:
5750
+ m["content"] = self._PARSE_ERROR_REPLACEMENT
5751
+ n += 1
5752
+ elif isinstance(c, list):
5753
+ for part in c:
5754
+ if (isinstance(part, dict) and isinstance(part.get("text"), str)
5755
+ and "Error in code parsing" in part["text"]):
5756
+ part["text"] = self._PARSE_ERROR_REPLACEMENT
5757
+ n += 1
5758
+ if n:
5759
+ _log(f"#113 replaced {n} code-parsing error message(s) with a "
5760
+ "delimiter-free directive (openai backend)")
5761
+
5480
5762
  def _prepare_completion_kwargs(self, *args, **kwargs):
5481
5763
  ck = super()._prepare_completion_kwargs(*args, **kwargs)
5764
+ try:
5765
+ self._sanitize_parse_errors(ck.get("messages", []) or [])
5766
+ except Exception as e: # noqa: BLE001
5767
+ _log(f"#113 parse-error sanitize skipped: {e}")
5482
5768
  # Disable Qwen3 "thinking" for the agent loop. Live runs showed thinking-on
5483
5769
  # spends the whole completion budget on a <think> trace and returns
5484
5770
  # content=None (no code block) → parse-error spiral → WS timeout. The
@@ -5656,6 +5942,10 @@ def run_agent_chat(cfg):
5656
5942
  stream_agent=_stream_coder,
5657
5943
  # Task #107: persist each code-model response to Mongo when the user opted in.
5658
5944
  save_full_response=save_full_response,
5945
+ # Task #113: enables the reasoning-model repairs (prose-only nudge, parse-error
5946
+ # sanitizing, no-code breaker). Explicit kind=openai ONLY — Ollama and Auto/MLX
5947
+ # never take those branches.
5948
+ openai_backend=(coding_kind == "openai"),
5659
5949
  # Cap each HTTP attempt at 600s so a genuinely slow single call (a
5660
5950
  # remote M6000/Vulkan box doing a cold ~4-5min prompt-eval) can finish
5661
5951
  # 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.317",
3
+ "version": "1.0.320",
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",