@tiens.nguyen/gonext-local-worker 1.0.318 → 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.
- package/gonext_agent_chat.py +309 -55
- package/package.json +1 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
|
@@ -4760,6 +4900,36 @@ def run_agent_chat(cfg):
|
|
|
4760
4900
|
"Do NOT hand an unfinished task back to the user.")
|
|
4761
4901
|
return True
|
|
4762
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
|
+
|
|
4763
4933
|
def _aggressive_compact(steps):
|
|
4764
4934
|
"""User-approved deep compaction: keep only the LAST step in full; for every
|
|
4765
4935
|
older step shrink the observation to a stub and the model_output to just its
|
|
@@ -4778,6 +4948,10 @@ def run_agent_chat(cfg):
|
|
|
4778
4948
|
_head = re.split(r"<code[\s>]|```", _mo, maxsplit=1)[0].rstrip()
|
|
4779
4949
|
_st.model_output = (_head[:200].rstrip() or _mo[:150].rstrip()) + "\n…[compacted]"
|
|
4780
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)
|
|
4781
4955
|
return saved
|
|
4782
4956
|
|
|
4783
4957
|
def step_callback(step_log):
|
|
@@ -4836,6 +5010,15 @@ def run_agent_chat(cfg):
|
|
|
4836
5010
|
_st.model_output = _new
|
|
4837
5011
|
_trim_n += 1
|
|
4838
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
|
|
4839
5022
|
if _trim_n:
|
|
4840
5023
|
_log(f"context trim: shrank {_trim_n} old step field(s), "
|
|
4841
5024
|
f"saved ~{_trim_saved} chars (#63/#87)")
|
|
@@ -4852,9 +5035,20 @@ def run_agent_chat(cfg):
|
|
|
4852
5035
|
_tu = getattr(step_log, "token_usage", None)
|
|
4853
5036
|
_in_tok = int(getattr(_tu, "input_tokens", 0) or 0) if _tu else 0
|
|
4854
5037
|
if _in_tok <= 0:
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
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
|
|
4858
5052
|
_in_tok = _chars // 4 + 7000 # + fixed system/reference baseline
|
|
4859
5053
|
if _in_tok >= _COMPACT_ASK_TOKENS and len(_steps) > 2:
|
|
4860
5054
|
if _compact_state["decision"] != "always":
|
|
@@ -4922,7 +5116,8 @@ def run_agent_chat(cfg):
|
|
|
4922
5116
|
# and any step memory it accumulates. completion_kwargs["messages"] here is the
|
|
4923
5117
|
# literal messages array sent to /v1/chat/completions.
|
|
4924
5118
|
class _LoggingModel(OpenAIServerModel):
|
|
4925
|
-
def __init__(self, *a, stream_agent=False, save_full_response=False,
|
|
5119
|
+
def __init__(self, *a, stream_agent=False, save_full_response=False,
|
|
5120
|
+
openai_backend=False, **kw):
|
|
4926
5121
|
# Running total of PROMPT (input) tokens sent to the CODE model across the
|
|
4927
5122
|
# turn (task #83). Only this wrapper counts — the chat/brain helper calls
|
|
4928
5123
|
# (_chat_completion) don't route through here, so this is "code model only".
|
|
@@ -4952,6 +5147,19 @@ def run_agent_chat(cfg):
|
|
|
4952
5147
|
# per-turn call counter used as the stored `step`.
|
|
4953
5148
|
self._save_full_response = bool(save_full_response)
|
|
4954
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
|
|
4955
5163
|
super().__init__(*a, **kw)
|
|
4956
5164
|
|
|
4957
5165
|
def _generate_once(self, *args, **kwargs):
|
|
@@ -5105,51 +5313,10 @@ def run_agent_chat(cfg):
|
|
|
5105
5313
|
return content, role, in_tok, out_tok, reasoning_len
|
|
5106
5314
|
|
|
5107
5315
|
def _recover_code_from_channels(self, content, reasoning):
|
|
5108
|
-
"""
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
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
|
|
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)
|
|
5153
5320
|
|
|
5154
5321
|
# Injected when a turn produced ONLY reasoning and no message content — a hard,
|
|
5155
5322
|
# model-agnostic steer to emit the actionable code block instead of more analysis.
|
|
@@ -5193,13 +5360,16 @@ def run_agent_chat(cfg):
|
|
|
5193
5360
|
# "regex pattern <code> was not found" error and burns the step before its
|
|
5194
5361
|
# own retry recovers. A finished PROSE answer has NO code tags, so it never
|
|
5195
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).
|
|
5196
5370
|
_pair_re = r"<code[^>]*>[\s\S]*?</code>"
|
|
5197
|
-
|
|
5198
|
-
|
|
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"
|
|
5371
|
+
why = _code_nudge_reason(content, self._openai_backend)
|
|
5372
|
+
if why:
|
|
5203
5373
|
_log(f"{why} turn (reasoning={reasoning_len} chars) → retrying once with a "
|
|
5204
5374
|
"'code now' directive")
|
|
5205
5375
|
_emit({"type": "step",
|
|
@@ -5509,12 +5679,92 @@ def run_agent_chat(cfg):
|
|
|
5509
5679
|
f"scratchpad-like prose ({len(stripped)} chars, no tool call) "
|
|
5510
5680
|
"→ NOT auto-wrapping; letting the step fail normally"
|
|
5511
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
|
|
5512
5714
|
except Exception as e: # noqa: BLE001
|
|
5513
5715
|
_log(f"content-normalize error: {e}")
|
|
5514
5716
|
return msg
|
|
5515
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
|
+
|
|
5516
5762
|
def _prepare_completion_kwargs(self, *args, **kwargs):
|
|
5517
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}")
|
|
5518
5768
|
# Disable Qwen3 "thinking" for the agent loop. Live runs showed thinking-on
|
|
5519
5769
|
# spends the whole completion budget on a <think> trace and returns
|
|
5520
5770
|
# content=None (no code block) → parse-error spiral → WS timeout. The
|
|
@@ -5692,6 +5942,10 @@ def run_agent_chat(cfg):
|
|
|
5692
5942
|
stream_agent=_stream_coder,
|
|
5693
5943
|
# Task #107: persist each code-model response to Mongo when the user opted in.
|
|
5694
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"),
|
|
5695
5949
|
# Cap each HTTP attempt at 600s so a genuinely slow single call (a
|
|
5696
5950
|
# remote M6000/Vulkan box doing a cold ~4-5min prompt-eval) can finish
|
|
5697
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.
|
|
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",
|