Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

167 lines
6.0 KiB
Python

"""Per-category error/retry rate detector for monitored agent streams."""
from __future__ import annotations
import re
import threading
import time
from collections import deque
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
_DEFAULT_WINDOW_SECONDS = 60.0
@dataclass(frozen=True, slots=True)
class ErrorCategory:
name: str
keywords: tuple[str, ...] = field(default=())
patterns: tuple[re.Pattern[str], ...] = field(default=())
# Patterns require error-shape context (adjacent verb, status prefix, header
# form, etc.) so descriptive prose like "the OpenAI rate limit is 10k tokens"
# or "add tool error handling" does not fire. See #1497 acceptance criterion.
DEFAULT_CATEGORIES: tuple[ErrorCategory, ...] = (
ErrorCategory(
name="rate_limit",
patterns=(
re.compile(
r"\brate[\s_-]?limit(?:ed|ing)?\b\s*"
r"(?:exceeded|hit|reached|encountered|triggered|error|429)",
re.IGNORECASE,
),
re.compile(r"\bratelimit(?:ed|ing)\b", re.IGNORECASE),
re.compile(
r"\b429\b\s*(?::|-|—|too\s+many\s+requests|rate)",
re.IGNORECASE,
),
),
),
ErrorCategory(
name="http_5xx",
patterns=(
re.compile(
r"(?:"
r"\bHTTP[/ ]\d(?:\.\d)?\s+5\d{2}\b"
r"|\bstatus(?:\s*code)?[: ]+5\d{2}\b"
r"|\b5\d{2}\s+(?:internal\s+server\s+error|bad\s+gateway|"
r"service\s+unavailable|gateway\s+timeout|server\s+error)\b"
r")",
re.IGNORECASE,
),
),
),
ErrorCategory(
name="tool_failure",
patterns=(
re.compile(r"\btool[\s_-]?failure\b\s*[:\-—]\s*\S", re.IGNORECASE),
re.compile(
r"\btool\s+failed\b\s+(?:during|at|with|in|on|while|"
r"after|when|to|for|because)\b",
re.IGNORECASE,
),
re.compile(r"\btool\s+exited\s+with\s+(?:code\s+)?\d+", re.IGNORECASE),
),
),
ErrorCategory(
name="traceback",
patterns=(re.compile(r"Traceback\s*\(most\s+recent\s+call\s+last\)"),),
),
)
class ErrorSignals:
"""Sliding-window error/retry rate tracker for an agent stdout stream.
Thread-safe: a ``threading.Lock`` guards the prune-and-mutate sections
of ``observe()`` and ``rate_per_minute()``. Regex matching runs outside
the lock so a heavy chunk does not block the renderer thread.
"""
__slots__ = (
"_categories",
"_window_seconds",
"_now",
"_events",
"_lock",
"_keyword_patterns",
)
def __init__(
self,
*,
categories: Iterable[ErrorCategory] | None = None,
window_seconds: float = _DEFAULT_WINDOW_SECONDS,
now: Callable[[], float] | None = None,
) -> None:
if window_seconds <= 0:
raise ValueError("window_seconds must be > 0")
resolved = tuple(categories if categories is not None else DEFAULT_CATEGORIES)
seen: set[str] = set()
for cat in resolved:
if cat.name in seen:
raise ValueError(f"duplicate category name: {cat.name!r}")
seen.add(cat.name)
self._categories: tuple[ErrorCategory, ...] = resolved
self._window_seconds = float(window_seconds)
self._now = now if now is not None else time.monotonic
self._events: dict[str, deque[float]] = {cat.name: deque() for cat in self._categories}
self._lock = threading.Lock()
# Pre-compile keywords with word boundaries so "error" does not match
# "errored" / "errorless" / "noerror". Keeps custom-category keyword
# use safe against adversarial substring inflation in agent stdout.
self._keyword_patterns: dict[str, tuple[re.Pattern[str], ...]] = {
cat.name: tuple(
re.compile(r"\b" + re.escape(kw) + r"\b", re.IGNORECASE)
for kw in cat.keywords
if kw
)
for cat in self._categories
}
def observe(self, chunk: str) -> None:
if not chunk:
return
timestamp = self._now()
cutoff = timestamp - self._window_seconds
# Run regex matching outside the lock so a heavy chunk does not block
# the renderer thread.
hits_by_category: list[tuple[str, int]] = []
for category in self._categories:
hits = self._count_hits(category, chunk)
if hits:
hits_by_category.append((category.name, hits))
# Prune in observe() too so an idle dashboard does not grow unbounded.
with self._lock:
for bucket in self._events.values():
while bucket and bucket[0] < cutoff:
bucket.popleft()
for name, hits in hits_by_category:
self._events[name].extend([timestamp] * hits)
def rate_per_minute(self) -> dict[str, float]:
cutoff = self._now() - self._window_seconds
scale = 60.0 / self._window_seconds
with self._lock:
rates: dict[str, float] = {}
for name, bucket in self._events.items():
while bucket and bucket[0] < cutoff:
bucket.popleft()
rates[name] = len(bucket) * scale
return rates
def _count_hits(self, category: ErrorCategory, chunk: str) -> int:
total = 0
# Keywords match with word boundaries via pre-compiled patterns.
for kw_pattern in self._keyword_patterns[category.name]:
total += sum(1 for _ in kw_pattern.finditer(chunk))
# finditer is unambiguous regardless of capturing-group structure;
# findall would return group contents instead of full matches if a
# future pattern adds a non-(?:...) group.
for pattern in category.patterns:
total += sum(1 for _ in pattern.finditer(chunk))
return total