@tiens.nguyen/gu-cli 1.0.749 → 1.0.751
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 +84 -10
- package/package.json +3 -1
package/gonext_agent_chat.py
CHANGED
|
@@ -3813,6 +3813,12 @@ def _normalize_code_tags(text: str) -> str:
|
|
|
3813
3813
|
<code> tag is present, so we don't touch backticks inside a real <code> block).
|
|
3814
3814
|
5. opened-but-never-closed (exactly one "<code>", no "</code>"): append "</code>"
|
|
3815
3815
|
so the parser can still extract the code instead of failing the whole step.
|
|
3816
|
+
6. NO DELIMITERS AT ALL, but the reply is Python that calls something — an optional
|
|
3817
|
+
"Thought:" line, then e.g. `print(read_file_lines(...))`. Measured on Claude: 13
|
|
3818
|
+
such replies in one turn, each thrown away as "no runnable code block" and re-asked,
|
|
3819
|
+
about a quarter of the turn's steps. Wrapped only when the code (after the Thought
|
|
3820
|
+
line) COMPILES and contains a call — prose does not parse as Python, so a sentence
|
|
3821
|
+
is never mistaken for code.
|
|
3816
3822
|
Idempotent — already-correct "<code>…</code>" is returned unchanged."""
|
|
3817
3823
|
if not text:
|
|
3818
3824
|
return text
|
|
@@ -3823,9 +3829,36 @@ def _normalize_code_tags(text: str) -> str:
|
|
|
3823
3829
|
fixed = _MD_FENCE_BLOCK.sub(lambda m: f"<code>\n{m.group(1)}</code>", fixed, count=1)
|
|
3824
3830
|
if fixed.count("<code>") == 1 and "</code>" not in fixed:
|
|
3825
3831
|
fixed = fixed + "\n</code>"
|
|
3832
|
+
if "<code>" not in fixed and "</code>" not in fixed:
|
|
3833
|
+
bare = _bare_code_block(fixed)
|
|
3834
|
+
if bare is not None:
|
|
3835
|
+
thought, code = bare
|
|
3836
|
+
fixed = (thought + "\n" if thought else "") + "<code>\n" + code + "\n</code>"
|
|
3826
3837
|
return fixed
|
|
3827
3838
|
|
|
3828
3839
|
|
|
3840
|
+
def _bare_code_block(text: str):
|
|
3841
|
+
"""(thought, code) when `text` is Python with no delimiters — an optional leading
|
|
3842
|
+
"Thought: …" line, then code that compiles and calls something. Else None."""
|
|
3843
|
+
import ast
|
|
3844
|
+
t = (text or "").strip()
|
|
3845
|
+
if not t:
|
|
3846
|
+
return None
|
|
3847
|
+
thought = ""
|
|
3848
|
+
lines = t.splitlines()
|
|
3849
|
+
if lines and lines[0].lstrip().lower().startswith("thought:"):
|
|
3850
|
+
thought, t = lines[0].strip(), "\n".join(lines[1:]).strip()
|
|
3851
|
+
if not t:
|
|
3852
|
+
return None
|
|
3853
|
+
try:
|
|
3854
|
+
tree = ast.parse(t)
|
|
3855
|
+
except SyntaxError:
|
|
3856
|
+
return None
|
|
3857
|
+
if not any(isinstance(n, ast.Call) for n in ast.walk(tree)):
|
|
3858
|
+
return None
|
|
3859
|
+
return thought, t
|
|
3860
|
+
|
|
3861
|
+
|
|
3829
3862
|
def _escape_raw_newlines_in_strings(code: str, defuse_tags: bool = False) -> str:
|
|
3830
3863
|
"""Turn RAW newlines/tabs that sit INSIDE a single/double-quoted string literal into
|
|
3831
3864
|
\\n/\\t escapes. Triple-quoted strings, comments, and everything outside a string are
|
|
@@ -6915,6 +6948,35 @@ def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) ->
|
|
|
6915
6948
|
+ out[-cap_tail:])
|
|
6916
6949
|
|
|
6917
6950
|
|
|
6951
|
+
# READ CAPS SCALE WITH THE WINDOW (issue #49). A flat 3,000 chars (~750 tokens, ~60-80 lines,
|
|
6952
|
+
# middle cut) was right for #63's weak models with no prompt cache. Measured over 625 Claude
|
|
6953
|
+
# steps it cut a read in 23 of 37 turns (>=14.6% of steps), and each cut is a follow-up read:
|
|
6954
|
+
# d6ad7211 walked a 6,665-line file in slices for ~40 steps. With cache reads at 0.1x a bigger
|
|
6955
|
+
# read costs about what the extra step did, and steps are what we save. Claude Code's own Read
|
|
6956
|
+
# allows 25k tokens. Small or unknown windows keep 3,000 — a 16k local Ollama is unchanged.
|
|
6957
|
+
_OBS_CAP_FLOOR = 3000
|
|
6958
|
+
_OBS_CAP_READ_MAX = 24_000 # ≈ read_file_lines' own 400-line limit
|
|
6959
|
+
_OBS_CAP_SEARCH_MAX = 12_000 # ≈ grep_repo's 100 hits / list_dir's 300 entries
|
|
6960
|
+
_OBS_CAP_WINDOW_SHARE = 0.03 # one read may take ~3% of the window
|
|
6961
|
+
_OBS_CAPS = {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR}
|
|
6962
|
+
|
|
6963
|
+
|
|
6964
|
+
def _obs_caps_for_window(window_tokens: int) -> dict:
|
|
6965
|
+
"""{read, search} character caps for a model with this context window (0 = unknown)."""
|
|
6966
|
+
if not window_tokens or window_tokens <= 0:
|
|
6967
|
+
return {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR}
|
|
6968
|
+
read = int(window_tokens * 4 * _OBS_CAP_WINDOW_SHARE) # ~4 chars per token
|
|
6969
|
+
read = max(_OBS_CAP_FLOOR, min(_OBS_CAP_READ_MAX, read))
|
|
6970
|
+
search = max(_OBS_CAP_FLOOR, min(_OBS_CAP_SEARCH_MAX, read // 2))
|
|
6971
|
+
return {"read": read, "search": search}
|
|
6972
|
+
|
|
6973
|
+
|
|
6974
|
+
def _set_obs_caps(window_tokens: int) -> dict:
|
|
6975
|
+
"""Set this turn's caps (the worker runs one turn per process)."""
|
|
6976
|
+
_OBS_CAPS.update(_obs_caps_for_window(window_tokens))
|
|
6977
|
+
return dict(_OBS_CAPS)
|
|
6978
|
+
|
|
6979
|
+
|
|
6918
6980
|
def _ws_cap_obs(text: str, cap: int = 3000) -> str:
|
|
6919
6981
|
"""Cap a READ tool's observation (read_file_lines / list_dir / read_text_file /
|
|
6920
6982
|
grep_repo) before it's returned to the CodeAgent. Every observation stays in the
|
|
@@ -8974,6 +9036,8 @@ def run_agent_chat(cfg):
|
|
|
8974
9036
|
_trim_state: dict = {"next_at": _TRIM_FIRST_AT_CHARS}
|
|
8975
9037
|
_ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
|
|
8976
9038
|
_compact_at = _compact_threshold(_ctx_window)
|
|
9039
|
+
_caps = _set_obs_caps(_ctx_window)
|
|
9040
|
+
_log(f"read caps: {_caps['read']} chars per read, {_caps['search']} per search (issue #49)")
|
|
8977
9041
|
if _ctx_window:
|
|
8978
9042
|
# SAY WHICH SOURCE ANSWERED. A measured window and a remembered one are not the same
|
|
8979
9043
|
# claim, and a table entry presented as fact is how a stale number survives unquestioned.
|
|
@@ -10310,7 +10374,11 @@ def run_agent_chat(cfg):
|
|
|
10310
10374
|
r"send_email)\s*\("
|
|
10311
10375
|
)
|
|
10312
10376
|
fence_is_toolcall = any(re.search(_tool_call_re, b) for b in fence_bodies)
|
|
10313
|
-
|
|
10377
|
+
# Bare Python with no delimiters at all (case 6 of _normalize_code_tags) —
|
|
10378
|
+
# it used to fall through to the prose branch and cost a whole step.
|
|
10379
|
+
bare_code = (not has_code_tag and not fence_bodies
|
|
10380
|
+
and _bare_code_block(content) is not None)
|
|
10381
|
+
if has_code_tag or fence_is_toolcall or bare_code:
|
|
10314
10382
|
normalized = _normalize_code_tags(content)
|
|
10315
10383
|
if normalized != content:
|
|
10316
10384
|
_log("normalized code-block tags (<code >/fence → <code>)")
|
|
@@ -10462,13 +10530,16 @@ def run_agent_chat(cfg):
|
|
|
10462
10530
|
# from the prompt, not writing code. Deliberately carries NO literal delimiter (the
|
|
10463
10531
|
# system prompt already shows the format with a worked example), so there is nothing
|
|
10464
10532
|
# to echo. Applied only when kind=openai; Ollama keeps the stock message.
|
|
10533
|
+
# STRONG MODELS (2026-09-25): the delimiters are named again. Leaving them out was a
|
|
10534
|
+
# workaround for Kimi echoing them; on Claude it did the opposite — told to use "the
|
|
10535
|
+
# delimiters shown in the system prompt", it replied with bare code, which failed the
|
|
10536
|
+
# same way, 13 times in one turn. And "call exactly one tool" contradicted batching.
|
|
10465
10537
|
_PARSE_ERROR_REPLACEMENT = (
|
|
10466
10538
|
"Error: your last reply contained no runnable code block, so nothing executed "
|
|
10467
10539
|
"and no observation was produced.\n"
|
|
10468
|
-
"Reply with ONE 'Thought:' line, then
|
|
10469
|
-
"
|
|
10470
|
-
"
|
|
10471
|
-
"Call exactly one tool, or call final_answer(...) if you are done."
|
|
10540
|
+
"Reply with ONE 'Thought:' line, then your Python between <code> and </code> — "
|
|
10541
|
+
"nothing else inside the block. Batch independent calls as usual, or call "
|
|
10542
|
+
"final_answer(...) if you are done."
|
|
10472
10543
|
)
|
|
10473
10544
|
|
|
10474
10545
|
def _sanitize_parse_errors(self, msgs):
|
|
@@ -10771,7 +10842,8 @@ def run_agent_chat(cfg):
|
|
|
10771
10842
|
entries.sort()
|
|
10772
10843
|
more = " …(truncated)" if len(entries) >= 300 else ""
|
|
10773
10844
|
out = _ws_cap_obs(
|
|
10774
|
-
f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more
|
|
10845
|
+
f"{len(entries)} files under {root}:\n" + "\n".join(entries) + more,
|
|
10846
|
+
cap=_OBS_CAPS["search"])
|
|
10775
10847
|
_emit_read("Listing files", path, f"{len(entries)} file(s)")
|
|
10776
10848
|
_last_obs["text"] = out
|
|
10777
10849
|
return out
|
|
@@ -10810,7 +10882,7 @@ def run_agent_chat(cfg):
|
|
|
10810
10882
|
return _rb
|
|
10811
10883
|
with open(rp, "r", encoding="utf-8", errors="replace") as fh:
|
|
10812
10884
|
data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
|
|
10813
|
-
out = _ws_cap_obs(data or "(empty file)")
|
|
10885
|
+
out = _ws_cap_obs(data or "(empty file)", cap=_OBS_CAPS["read"])
|
|
10814
10886
|
_emit_read("Reading file", path, f"{data.count(chr(10)) + 1} line(s)"
|
|
10815
10887
|
if data else "empty")
|
|
10816
10888
|
_last_obs["text"] = out
|
|
@@ -11469,7 +11541,8 @@ def run_agent_chat(cfg):
|
|
|
11469
11541
|
_last_obs["text"] = msg
|
|
11470
11542
|
return msg
|
|
11471
11543
|
more = "\n…(capped at 100 hits)" if len(hits) >= 100 else ""
|
|
11472
|
-
out = _ws_cap_obs(f"Matches under {root}:\n" + "\n".join(hits) + more
|
|
11544
|
+
out = _ws_cap_obs(f"Matches under {root}:\n" + "\n".join(hits) + more,
|
|
11545
|
+
cap=_OBS_CAPS["search"])
|
|
11473
11546
|
_last_obs["text"] = out
|
|
11474
11547
|
return out
|
|
11475
11548
|
except Exception as e: # noqa: BLE001
|
|
@@ -11555,7 +11628,7 @@ def run_agent_chat(cfg):
|
|
|
11555
11628
|
_emit({"type": "step",
|
|
11556
11629
|
"text": f"Locating symbol → {name} ({len(defs)} def, "
|
|
11557
11630
|
f"{len(calls)} call, {len(tests)} test)"})
|
|
11558
|
-
res = _ws_cap_obs("\n".join(out))
|
|
11631
|
+
res = _ws_cap_obs("\n".join(out), cap=_OBS_CAPS["search"])
|
|
11559
11632
|
_last_obs["text"] = res
|
|
11560
11633
|
return res
|
|
11561
11634
|
except Exception as ex: # noqa: BLE001
|
|
@@ -11594,7 +11667,8 @@ def run_agent_chat(cfg):
|
|
|
11594
11667
|
_last_obs["text"] = _rb
|
|
11595
11668
|
return _rb
|
|
11596
11669
|
body = "".join(f"{i}: {lines[i - 1]}" for i in range(s, e + 1))
|
|
11597
|
-
out = _ws_cap_obs(f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}"
|
|
11670
|
+
out = _ws_cap_obs(f"{rp} (lines {s}-{e} of {len(lines)}):\n{body}",
|
|
11671
|
+
cap=_OBS_CAPS["read"])
|
|
11598
11672
|
_search_tally_bump("read")
|
|
11599
11673
|
_emit_read("Reading lines", f"{path}:{s}-{e}", f"of {len(lines)} line(s)")
|
|
11600
11674
|
_last_obs["text"] = out
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.751",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
"test:cache-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/prefix-cache-e2e.test.mjs",
|
|
38
38
|
"test:tokens": "python3 -m unittest discover tests -p \"test_cached_tokens.py\" -v && node --test tests/prefix-cache.test.mjs tests/agent-turn-shape.test.mjs",
|
|
39
39
|
"test:selection": "node --test tests/selection-scrollback.test.mjs tests/selection-while-thinking.test.mjs tests/terminal-selection.test.mjs tests/terminal-copy.test.mjs tests/terminal-viewport.test.mjs tests/hover-repaint.test.mjs",
|
|
40
|
+
"test:steps": "python3 scripts/py-tap.py tests/test_read_caps.py tests/test_trim_payback.py tests/test_explore.py tests/test_and_chains.py tests/test_edit_file_primary.py tests/test_bare_code_rescue.py tests/test_batching_rule.py tests/test_no_pdf_routing.py",
|
|
41
|
+
"test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
|
|
40
42
|
"capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
|
|
41
43
|
"test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
|
|
42
44
|
"test:bearer-e2e": "bash scripts/bearer-e2e.sh"
|