4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
111 lines
3.8 KiB
Python
111 lines
3.8 KiB
Python
"""Quality signals for monitored local agent streams (loop detection, etc.)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from collections import Counter, deque
|
|
|
|
|
|
def _shingle_fingerprint(shingle: tuple[str, ...]) -> int:
|
|
"""Return a stable 64-bit int for *shingle* (same input → same value across processes)."""
|
|
payload = b"\0".join(t.encode("utf-8") for t in shingle)
|
|
digest = hashlib.blake2b(payload, digest_size=8).digest()
|
|
return int.from_bytes(digest, "big")
|
|
|
|
|
|
class LoopDetector:
|
|
"""Rolling w-shingle detector for repetitive agent output.
|
|
|
|
Ingests text chunks, tokenizes on whitespace (lowercased), and hashes
|
|
consecutive token windows of size ``shingle_size``. Shingles are fingerprinted
|
|
with BLAKE2b (8-byte digest) so counts are stable across processes. The last
|
|
``window`` emitted fingerprint values are retained; if any fingerprint appears
|
|
strictly more than ``threshold`` times in that deque, :attr:`is_looping` is true.
|
|
|
|
Each logical shingle add or remove is O(1). :meth:`observe` costs
|
|
O(number of tokens derived from *chunk*).
|
|
"""
|
|
|
|
__slots__ = (
|
|
"_pending",
|
|
"_shingle_size",
|
|
"_window",
|
|
"_shingle_hashes",
|
|
"_hash_count",
|
|
"_threshold",
|
|
"_violators",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
window: int = 20,
|
|
threshold: int = 4,
|
|
shingle_size: int = 3,
|
|
) -> None:
|
|
if window < 1:
|
|
raise ValueError("window must be >= 1")
|
|
if threshold < 0:
|
|
raise ValueError("threshold must be >= 0")
|
|
if shingle_size < 1:
|
|
raise ValueError("shingle_size must be >= 1")
|
|
|
|
self._shingle_size = shingle_size
|
|
self._window = window
|
|
self._threshold = threshold
|
|
self._pending: deque[str] = deque(maxlen=shingle_size)
|
|
self._shingle_hashes: deque[int] = deque(maxlen=window)
|
|
self._hash_count: Counter[int] = Counter()
|
|
self._violators: int = 0
|
|
|
|
def clear(self) -> None:
|
|
"""Drop all buffered tokens, shingles, and counts."""
|
|
self._pending.clear()
|
|
self._shingle_hashes.clear()
|
|
self._hash_count.clear()
|
|
self._violators = 0
|
|
|
|
def observe(self, chunk: str) -> None:
|
|
"""Feed a text fragment; updates shingles and loop state."""
|
|
for token in self._tokens_from_chunk(chunk):
|
|
self._pending.append(token)
|
|
if len(self._pending) == self._shingle_size:
|
|
shingle: tuple[str, ...] = tuple(self._pending)
|
|
self._push_hash(_shingle_fingerprint(shingle))
|
|
self._pending.popleft()
|
|
|
|
@property
|
|
def is_looping(self) -> bool:
|
|
"""True when some shingle fingerprint exceeds ``threshold`` in the rolling window."""
|
|
return self._violators > 0
|
|
|
|
def _tokens_from_chunk(self, chunk: str) -> list[str]:
|
|
if not chunk or not chunk.strip():
|
|
return []
|
|
# ``str.split()`` with no sep splits on arbitrary whitespace and omits empties.
|
|
return [p.lower() for p in chunk.split()]
|
|
|
|
def _push_hash(self, h: int) -> None:
|
|
if len(self._shingle_hashes) == self._shingle_hashes.maxlen:
|
|
evicted = self._shingle_hashes.popleft()
|
|
self._decrement_hash(evicted)
|
|
self._shingle_hashes.append(h)
|
|
self._increment_hash(h)
|
|
|
|
def _increment_hash(self, h: int) -> None:
|
|
prev = self._hash_count[h]
|
|
new = prev + 1
|
|
self._hash_count[h] = new
|
|
if prev <= self._threshold < new:
|
|
self._violators += 1
|
|
|
|
def _decrement_hash(self, h: int) -> None:
|
|
prev = self._hash_count[h]
|
|
new = prev - 1
|
|
if new == 0:
|
|
del self._hash_count[h]
|
|
else:
|
|
self._hash_count[h] = new
|
|
if prev > self._threshold >= new:
|
|
self._violators -= 1
|