@tiens.nguyen/gu-cli 1.0.823 → 1.0.825

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.
@@ -2026,7 +2026,11 @@ def _is_greeting_smalltalk(text: str) -> bool:
2026
2026
  # self-labels as "Thought"/"Thinking Process" up front (a real answer doesn't open by
2027
2027
  # naming itself as the model's own reasoning), or it's dense with self-correction
2028
2028
  # language a finished answer wouldn't contain.
2029
- _SCRATCHPAD_START_RE = re.compile(r"^\s*thought\b\s*[:\n]", re.I)
2029
+ # The label may come dressed as markdown — "## Thought" or "**Thought:**" — and is still the
2030
+ # model naming its own reasoning (#139: Kimi K3 wrote "## Thought\n\n… Let me check git status"
2031
+ # and it was delivered as the ANSWER to "commit", so nothing ran).
2032
+ _SCRATCHPAD_START_RE = re.compile(
2033
+ r"^\s*(?:#{1,6}\s*|\*\*|__)?thought(?:\s*:|\s*(?:\*\*|__)\s*:?|\s*\n)", re.I)
2030
2034
  _DELIBERATION_RE = re.compile(
2031
2035
  r"\b(wait,|actually,|let'?s (try|just|use|list|see|go)|"
2032
2036
  r"i (?:need to|should) (?:check|verify|confirm))\b",
@@ -2194,11 +2198,41 @@ def _machine_context(platform_name: str = "", arch: str = "", cwd: str = "") ->
2194
2198
  )
2195
2199
 
2196
2200
 
2201
+ # A reply that ENDS by announcing its next action is a plan, not an answer (#139, live: "Two
2202
+ # logical changes to commit: 1. … 2. … Let me check what changed in api/latest.json …" was
2203
+ # wrapped as the answer to "commit" because its numbered list looked finished). Only the LAST
2204
+ # paragraph counts, "let me know" / "I'll be" are not actions, and a step handed to the user
2205
+ # ("I'll push when you say so") is a finished answer.
2206
+ _NEXT_STEP_RE = re.compile(
2207
+ r"\b(?:let me|let's|let us|i'll|i will|i'm going to|i am going to|i need to|"
2208
+ r"next,? i(?:'ll| will| need to)?|now i(?:'ll| will)?)\s+(?:also\s+|now\s+|first\s+|quickly\s+)?"
2209
+ r"(?:check|look|see|read|run|inspect|verify|review|examine|find|search|grep|open|"
2210
+ r"stage|commit|create|write|edit|fix|update|add|build|test|install|start|try|"
2211
+ r"fetch|list|compare|construct|draft|prepare|explore|query|confirm|diff|do)\b",
2212
+ re.I,
2213
+ )
2214
+ _HANDED_TO_USER_RE = re.compile(
2215
+ r"\b(?:when|if|once|whenever|after) you\b|\byour (?:call|go-ahead|word|court)\b|"
2216
+ r"\band i(?:'ll| will)\b|\b(?:tell|give|ask|show) me\b|\bsay (?:the word|which|'|\")", re.I)
2217
+
2218
+
2219
+ def _announces_next_step(stripped: str) -> bool:
2220
+ """True if the reply's last paragraph says what the model will do NEXT (and it did not
2221
+ do it). Pure, so the live replies can be replayed."""
2222
+ paras = [p for p in re.split(r"\n\s*\n", stripped or "") if p.strip()]
2223
+ if not paras:
2224
+ return False
2225
+ last = paras[-1]
2226
+ return bool(_NEXT_STEP_RE.search(last)) and not _HANDED_TO_USER_RE.search(last)
2227
+
2228
+
2197
2229
  def _looks_like_scratchpad(stripped: str) -> bool:
2198
2230
  """True if `stripped` reads like unresolved internal deliberation rather than a
2199
- finished, user-facing answer — see module comment above for the two signals used."""
2231
+ finished, user-facing answer — see module comment above for the signals used."""
2200
2232
  if _SCRATCHPAD_START_RE.match(stripped):
2201
2233
  return True
2234
+ if _announces_next_step(stripped):
2235
+ return True
2202
2236
  return len(_DELIBERATION_RE.findall(stripped)) >= 3
2203
2237
 
2204
2238
 
@@ -5224,6 +5258,37 @@ def _has_runnable_block(text) -> bool:
5224
5258
  return False
5225
5259
 
5226
5260
 
5261
+ # Chat-template control tokens a served model sometimes prints instead of acting on (#139,
5262
+ # Kimi K3 on ollama.com: "Thought: …<|close|>think<|sep|><|open|>response<|sep|>"). They mark
5263
+ # where the model's own reasoning ended and its reply began.
5264
+ _TEMPLATE_TOKEN_RE = re.compile(r"<\|[a-z_]{2,24}\|>(?:\s*[a-z_]{2,24}\s*<\|[a-z_]{2,24}\|>)*", re.I)
5265
+
5266
+
5267
+ def _strip_leaked_template(content):
5268
+ """Cut leaked chat-template tokens out of a reply; keep what came after the last one.
5269
+
5270
+ Text before the last token is the model's reasoning written into the visible channel; text
5271
+ after it is the reply. An EMPTY reply stays empty, so the empty-content nudge asks the model
5272
+ to act instead of the step failing on a plan that ran nothing (seen live: three steps of
5273
+ "Thought: … now final answer.<|close|>think…" before a final_answer). Tokens inside a
5274
+ <code> block are file content, never ours to touch."""
5275
+ if not isinstance(content, str) or "<|" not in content:
5276
+ return content
5277
+ spans = [m.span() for m in re.finditer(_CODE_PAIR_RE, content, re.I)]
5278
+ last = None
5279
+ for m in _TEMPLATE_TOKEN_RE.finditer(content):
5280
+ if not any(a <= m.start() < b for a, b in spans):
5281
+ last = m
5282
+ if last is None:
5283
+ return content
5284
+ reply = content[last.end():].strip()
5285
+ if reply:
5286
+ return reply
5287
+ head = _TEMPLATE_TOKEN_RE.sub("", content[:last.start()]).strip()
5288
+ # A runnable block before the tokens is still the action — keep it.
5289
+ return head if _has_runnable_block(head) else ""
5290
+
5291
+
5227
5292
  def _code_nudge_reason(content, openai_backend: bool):
5228
5293
  """Why (if at all) this streamed turn should be re-prompted once with the CODE-NOW
5229
5294
  directive — or None to accept it as-is. Pure decision so the three triggers can be
@@ -5234,14 +5299,16 @@ def _code_nudge_reason(content, openai_backend: bool):
5234
5299
  merely TRUNCATED at the `</code>` stop sequence is not that (task #119): it is
5235
5300
  closed first and accepted as-is, so a step that already called a tool no longer
5236
5301
  pays for a second model call to be told to do what it just did.
5237
- 3. prose-only plan — narration of what it is ABOUT to do, no tag at all. This one
5238
- is openai-only and additionally requires _looks_like_scratchpad, so a FINISHED
5239
- prose answer is never nudged into inventing another tool call. It exists because
5240
- the "</code>" visible in a smolagents trace is appended by smolagents AFTER the
5241
- model returns, so trigger 2 cannot see this shape.
5242
-
5243
- Triggers 1 and 2 keep their existing backend-agnostic behavior — the Ollama path is
5244
- unchanged, since trigger 3 is the only new one and it is gated off there."""
5302
+ 3. prose-only plan — narration of what it is ABOUT to do, no tag at all. It
5303
+ requires _looks_like_scratchpad, so a FINISHED prose answer is never nudged into
5304
+ inventing another tool call. It exists because the "</code>" visible in a
5305
+ smolagents trace is appended by smolagents AFTER the model returns, so trigger 2
5306
+ cannot see this shape.
5307
+
5308
+ All three apply on every backend. Trigger 3 was openai-only (kept off small local
5309
+ Ollama models); #139 showed the cost: Kimi K3 served by ollama.com is an "ollama"
5310
+ backend, so each plan-only reply burned a whole failed step instead of one retry.
5311
+ `openai_backend` is kept for callers and no longer changes the decision."""
5245
5312
  text = (content or "").strip()
5246
5313
  if not text:
5247
5314
  return "empty-content"
@@ -5249,7 +5316,7 @@ def _code_nudge_reason(content, openai_backend: bool):
5249
5316
  return None # a real block (closing a stop-truncated one first) — nothing to nudge
5250
5317
  if re.search(r"</?code[\s>/]", content, re.I):
5251
5318
  return "dangling code tag, no valid block"
5252
- if openai_backend and _looks_like_scratchpad(text):
5319
+ if _looks_like_scratchpad(text):
5253
5320
  return "prose-only plan, no code block"
5254
5321
  return None
5255
5322
 
@@ -10160,7 +10227,10 @@ def run_agent_chat(cfg):
10160
10227
  "find a URL, THEN fetch_url to read it). You SEE the Observation before the next step. "
10161
10228
  "When you have enough to answer, call final_answer(<your answer>) in a code block — pass a readable "
10162
10229
  "markdown/prose recap (what you did, what you verified, what is left), never a dict, list or JSON. "
10163
- "Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
10230
+ "Prefer FEWER steps, and never repeat a tool call that already succeeded. "
10231
+ # #139: Kimi K3 replied "…Let me check X" with no block, and that plan became the answer.
10232
+ "A reply WITHOUT a code block ENDS your turn and is shown to the user as your answer — "
10233
+ "never end on \"Let me check…\"; if there is more to do, put that call in the block NOW.\n\n"
10164
10234
  "{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
10165
10235
  + _skills_mod.prompt_block(_skills)
10166
10236
  + _rag_tool_block + _ws_tool_block + _auto_test_block +
@@ -11730,6 +11800,12 @@ def run_agent_chat(cfg):
11730
11800
  # anything downstream asks "did this turn produce a code block?" — otherwise a
11731
11801
  # step that called a tool correctly reads as broken. Gated on the code actually
11732
11802
  # compiling, so a genuinely dangling tag still falls through to the nudge below.
11803
+ # Leaked chat-template tokens first (#139): what came before them is reasoning.
11804
+ _unleaked = _strip_leaked_template(content)
11805
+ if _unleaked != content:
11806
+ _log(f"leaked chat-template tokens → kept the reply after them "
11807
+ f"({len(content)} → {len(_unleaked)} chars)")
11808
+ content = _unleaked
11733
11809
  content = _close_dangling_code_block(content)
11734
11810
  # TAGS FIRST, judgement second. The tag repair used to run much later, so every
11735
11811
  # decision in between — is this a code block? does it need a nudge? — was made on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.823",
3
+ "version": "1.0.825",
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",