Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,16 @@ agent:
# primaries (default 3). The OpenAI SDK does its own low-level retries
# underneath this wrapper β€” this is the Hermes-level loop.
# api_max_retries: 3


# Ollama/GLM truncation heuristic. When using Ollama-hosted GLM models,
# some finish_reason='stop' responses are actually truncated. This heuristic
# detects such cases and requests continuations. However, it can false-trigger
# on responses ending with emoji sign-offs or conversational text lacking
# terminal punctuation (see #14572). The heuristic now has guard rails
# (500-char minimum, emoji recognition) but if you still hit false positives,
# set this to false to disable it entirely.
# glm_truncation_heuristic: true

# Enable verbose logging
verbose: false

Expand Down
88 changes: 85 additions & 3 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import tempfile
import time
import threading
import unicodedata
from types import SimpleNamespace
import urllib.request
import uuid
Expand Down Expand Up @@ -1700,6 +1701,17 @@ def __init__(
_agent_section = {}
self._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")

# Ollama/GLM stop-to-length truncation heuristic. Enabled by default
# but can be disabled via agent.glm_truncation_heuristic: false in
# config.yaml. See #14572 for the false-positive bug that led to
# this being made configurable.
_glm_heuristic = _agent_section.get("glm_truncation_heuristic", True)
self._glm_truncation_heuristic_enabled = (
str(_glm_heuristic).lower() in ("true", "1", "yes")
if isinstance(_glm_heuristic, str)
else bool(_glm_heuristic)
)

# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
try:
Expand Down Expand Up @@ -2799,15 +2811,55 @@ def _strip_think_blocks(self, content: str) -> str:

@staticmethod
def _has_natural_response_ending(content: str) -> bool:
"""Heuristic: does visible assistant text look intentionally finished?"""
"""Heuristic: does visible assistant text look intentionally finished?

Recognises ASCII/CJK punctuation, emoji, and other common sign-off
glyphs as natural endings. Returns True for characters that are
unlikely to appear mid-sentence in a truncated response.

Extended in #14572 to cover emoji sign-offs (e.g. πŸ’›, ✨, πŸ™Œ)
which were previously false-positive triggers for the Ollama/GLM
stop-to-length heuristic.
"""
if not content:
return False
stripped = content.rstrip()
if not stripped:
return False
if stripped.endswith("```"):
return True
return stripped[-1] in '.!?:)"\']}γ€‚οΌοΌŸοΌšοΌ‰γ€‘γ€γ€γ€‹'

# Strip trailing variation selectors (U+FE0F) and zero-width joiners
# (U+200D) that emoji sequences use, so we check the "real" base glyph.
i = len(stripped) - 1
while i >= 0 and unicodedata.category(stripped[i]) in ("Mn", "Me", "Cf"):
i -= 1
if i < 0:
return False
last_char = stripped[i]

# ASCII and CJK punctuation that signal a complete thought.
if last_char in '.!?:)"\']}γ€‚οΌοΌŸοΌšοΌ‰γ€‘γ€γ€γ€‹':
return True

# Emoji and other Unicode sign-off glyphs.
# We use unicodedata categories rather than a hard-coded codepoint
# list so we automatically cover new emoji as Python's Unicode
# database grows.
cat = unicodedata.category(last_char)
# So (Other_Symbol) covers ✨ πŸ’ͺ πŸš€ βœ… ❌ ⚠ etc.
# Sk (Modifier_Symbol) covers VS16 (❀️ variation selector, etc.)
# Sm (Math_Symbol) covers β†’ ← ∞ β‰ˆ and similar sign-off glyphs.
if cat in ("So", "Sk", "Sm"):
return True
# Emoji_Presentation property: many emoji are General_Category=So
# but some are in Lo/Lm/Other. Check the wide "Extended_Pictographic"
# property via the Emoji character range heuristic (U+1F000..U+1FAFF).
cp = ord(last_char)
if 0x1F000 <= cp <= 0x1FAFF:
return True

return False

def _is_ollama_glm_backend(self) -> bool:
"""Detect the narrow backend family affected by Ollama/GLM stop misreports."""
Expand All @@ -2825,7 +2877,28 @@ def _should_treat_stop_as_truncated(
assistant_message,
messages: Optional[list] = None,
) -> bool:
"""Detect conservative stop->length misreports for Ollama-hosted GLM models."""
"""Detect conservative stop->length misreports for Ollama-hosted GLM models.

The Ollama/GLM backend sometimes reports finish_reason='stop' on
responses that were actually truncated by the max_tokens limit.
This heuristic detects such cases by looking for responses that
appear to end mid-sentence (no natural ending punctuation or emoji).

Guard rails (to avoid false positives, cf. #14572):
- Config flag agent.glm_truncation_heuristic (default True) disables the
heuristic entirely when set to False.
- Short responses (<500 chars with whitespace) are almost certainly
complete β€” a truly truncated response would be long enough to hit
the token limit.
- Only applies after tool-use turns (Ollama/GLM is known to
misreport stop-after-tool continuations).
- Responses ending with emoji or other Unicode sign-off glyphs are
treated as naturally complete (see _has_natural_response_ending).
"""
# Config opt-out: if the user has disabled the heuristic, never trigger.
if not getattr(self, "_glm_truncation_heuristic_enabled", True):
return False

if finish_reason != "stop" or self.api_mode != "chat_completions":
return False
if not self._is_ollama_glm_backend():
Expand All @@ -2845,8 +2918,17 @@ def _should_treat_stop_as_truncated(
visible_text = self._strip_think_blocks(content).strip()
if not visible_text:
return False
# Very short responses with spaces are almost certainly complete β€”
# they couldn't have hit a meaningful token limit.
if len(visible_text) < 20 or not re.search(r"\s", visible_text):
return False
# Short-to-medium responses (<500 chars) are very unlikely to be
# truncated. Raising this gate from 20 to 500 eliminates the vast
# majority of false positives from conversational replies that simply
# lack terminal punctuation. (See #14572 for the original bug where
# emoji sign-offs triggered continuation loops on every turn.)
if len(visible_text) < 500:
return False

return not self._has_natural_response_ending(visible_text)

Expand Down
Loading