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

283 lines
11 KiB
Python

"""Pluggable pattern-rule registry for the Hermes incident classifier.
The built-in :class:`IncidentClassifier` ships with three structural rules
(``error_severity``, ``traceback``, ``warning_burst``). Those cover *log
shape* — a record's level or a sequence of records — but they don't know
anything about the *content* that has actually broken Hermes in
production: OOM kills, crash loops, context-window overruns, auth
bypasses, rate limits, WAL growth, etc.
Pattern rules close that gap. A :class:`PatternRule` matches a
:class:`LogRecord`'s message against one or more regex patterns and
emits a :class:`HermesIncident` with a stable ``rule`` name. The
classifier accepts an arbitrary list of pattern rules at construction
time, so operators can ship their own without modifying core logic.
The default registry — :func:`default_pattern_rules` — encodes the
operational failure modes observed in the synthetic test corpus
(``tests/synthetic/hermes/scenarios/``), all of which trace back to
real hermes-agent GitHub issues:
* ``oom_killed`` — Linux OOM-killer / Python ``MemoryError``
* ``crash_loop`` — repeated agent restart in a window
* ``context_window_exceeded`` — model token-limit overruns
* ``auth_failure`` — token / auth bypass anomalies
* ``rate_limit`` — provider 429 / quota exceeded
* ``database_wal_growth`` — sqlite / postgres WAL pressure
* ``deadlock`` — explicit deadlock detection
* ``disk_full`` — ENOSPC / no space left on device
"""
from __future__ import annotations
import re
from collections import deque
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Final
from integrations.hermes.incident import HermesIncident, IncidentSeverity, LogLevel, LogRecord
@dataclass(frozen=True, slots=True)
class PatternRule:
"""Rule that emits one incident per matching log record.
Patterns are matched case-insensitively against ``record.message``
only. ``record.raw`` (which includes the timestamp/level/logger
prefix) is intentionally excluded so a keyword appearing in a
logger name — e.g. ``oom_monitor`` — cannot fire an OOM rule on
an otherwise benign message.
"""
name: str
severity: IncidentSeverity
title_template: str
patterns: tuple[re.Pattern[str], ...]
min_level: LogLevel = LogLevel.WARNING
def evaluate(self, record: LogRecord) -> HermesIncident | None:
if record.is_continuation:
return None
if record.level.severity_rank < self.min_level.severity_rank:
return None
for pattern in self.patterns:
if pattern.search(record.message):
title = self.title_template.format(
logger=record.logger or "unknown",
level=record.level.value,
)
return HermesIncident(
rule=self.name,
severity=self.severity,
title=title,
detected_at=record.timestamp,
logger=record.logger,
fingerprint=_fingerprint(self.name, record.logger, pattern.pattern),
records=(record,),
run_id=record.run_id,
)
return None
@dataclass
class RepeatRule:
"""Rule that fires when the same pattern matches N times in a window.
Used for ``crash_loop`` and any other failure mode that's only
actionable when it repeats. Each rule keeps its own bounded deque
keyed by logger; older matches age out automatically.
As with :class:`PatternRule`, patterns match against
``record.message`` only — never the raw line — so logger names
can't accidentally satisfy a rule.
"""
name: str
severity: IncidentSeverity
title_template: str
patterns: tuple[re.Pattern[str], ...]
threshold: int
window: timedelta
min_level: LogLevel = LogLevel.WARNING
_hits: dict[str, deque[LogRecord]] = field(default_factory=dict)
def evaluate(self, record: LogRecord) -> HermesIncident | None:
if record.is_continuation:
return None
if record.level.severity_rank < self.min_level.severity_rank:
return None
if not any(p.search(record.message) for p in self.patterns):
return None
key = record.logger or "_unknown"
bucket = self._hits.setdefault(key, deque())
bucket.append(record)
cutoff = record.timestamp - self.window
while bucket and bucket[0].timestamp < cutoff:
bucket.popleft()
if len(bucket) < self.threshold:
return None
contributing = tuple(bucket)
bucket.clear()
title = self.title_template.format(
logger=record.logger or "unknown",
count=len(contributing),
seconds=int(self.window.total_seconds()),
)
return HermesIncident(
rule=self.name,
severity=self.severity,
title=title,
detected_at=record.timestamp,
logger=record.logger,
fingerprint=_fingerprint(self.name, record.logger, ""),
records=contributing,
run_id=record.run_id,
)
def default_pattern_rules() -> list[PatternRule | RepeatRule]:
"""Return the default operational pattern-rule set.
Patterns are curated from real hermes-agent issue reports (see
``tests/synthetic/hermes/scenarios/``); each is intentionally
permissive about surrounding text so logger format changes don't
silently break detection.
"""
return [
PatternRule(
name="oom_killed",
severity=IncidentSeverity.CRITICAL,
title_template="Out-of-memory event in {logger}",
patterns=(
re.compile(r"\bMemoryError\b", re.IGNORECASE),
re.compile(r"out of memory", re.IGNORECASE),
re.compile(r"\boom[- _]killer", re.IGNORECASE),
re.compile(r"killed process \d+", re.IGNORECASE),
re.compile(r"cgroup .* out of memory", re.IGNORECASE),
),
min_level=LogLevel.WARNING,
),
PatternRule(
name="context_window_exceeded",
severity=IncidentSeverity.HIGH,
title_template="Context-window overrun in {logger}",
patterns=(
re.compile(r"context[_ -]?length[_ -]?exceeded", re.IGNORECASE),
re.compile(r"context window", re.IGNORECASE),
re.compile(r"maximum context length", re.IGNORECASE),
re.compile(r"\d{4,}\s+tokens?.*(?:exceed|over|limit|max)", re.IGNORECASE),
re.compile(r"prompt is too long", re.IGNORECASE),
),
min_level=LogLevel.WARNING,
),
PatternRule(
name="auth_failure",
severity=IncidentSeverity.HIGH,
title_template="Authentication failure in {logger}",
patterns=(
re.compile(r"\b401\b.*unauthori[sz]ed", re.IGNORECASE),
re.compile(r"\b403\b.*forbidden", re.IGNORECASE),
re.compile(r"invalid (?:api[- _]?key|token|credentials)", re.IGNORECASE),
re.compile(r"auth(?:entication)?\s+(?:failed|denied|bypass)", re.IGNORECASE),
re.compile(r"signature (?:mismatch|invalid)", re.IGNORECASE),
),
min_level=LogLevel.WARNING,
),
PatternRule(
name="rate_limit",
severity=IncidentSeverity.MEDIUM,
title_template="Rate-limit / quota hit in {logger}",
patterns=(
re.compile(r"\b429\b", re.IGNORECASE),
re.compile(r"rate[- ]?limit(?:ed|ing|exceeded)?", re.IGNORECASE),
re.compile(r"quota (?:exceeded|exhausted)", re.IGNORECASE),
re.compile(r"too many requests", re.IGNORECASE),
),
min_level=LogLevel.WARNING,
),
PatternRule(
name="database_wal_growth",
severity=IncidentSeverity.HIGH,
title_template="Database WAL pressure in {logger}",
patterns=(
re.compile(r"\bWAL\b.*(?:grow|size|bytes|backlog)", re.IGNORECASE),
re.compile(r"write[- ]?ahead[- ]?log", re.IGNORECASE),
re.compile(r"checkpoint .* (?:lagging|behind|failed)", re.IGNORECASE),
),
min_level=LogLevel.WARNING,
),
PatternRule(
name="deadlock",
severity=IncidentSeverity.HIGH,
title_template="Deadlock detected in {logger}",
patterns=(
re.compile(r"deadlock detected", re.IGNORECASE),
re.compile(r"\bdeadlock\b.*(?:victim|abort|rollback)", re.IGNORECASE),
),
min_level=LogLevel.WARNING,
),
PatternRule(
name="disk_full",
severity=IncidentSeverity.CRITICAL,
title_template="Disk full in {logger}",
patterns=(
re.compile(r"no space left on device", re.IGNORECASE),
re.compile(r"\bENOSPC\b"),
re.compile(r"disk (?:is )?full", re.IGNORECASE),
),
min_level=LogLevel.WARNING,
),
RepeatRule(
name="crash_loop",
severity=IncidentSeverity.CRITICAL,
title_template="Crash loop in {logger}: {count} restarts in {seconds}s",
patterns=(
re.compile(
r"(?:agent|process|service|runtime)\s+(?:re)?start(?:ing|ed)?",
re.IGNORECASE,
),
re.compile(r"unexpected (?:exit|shutdown|termination)", re.IGNORECASE),
re.compile(r"exited with (?:status|code)\s*\d+", re.IGNORECASE),
),
threshold=3,
window=timedelta(seconds=120),
min_level=LogLevel.WARNING,
),
]
_FINGERPRINT_SEPARATOR: Final[str] = "|"
def _fingerprint(rule: str, logger_name: str | None, extra: str) -> str:
import hashlib
digest = hashlib.sha256(
f"{rule}{_FINGERPRINT_SEPARATOR}{logger_name or ''}{_FINGERPRINT_SEPARATOR}{extra}".encode(),
)
return digest.hexdigest()[:16]
def evaluate_all(
rules: Sequence[PatternRule | RepeatRule],
records: Iterable[LogRecord],
) -> list[HermesIncident]:
"""Run a fresh pass of *rules* across *records* in order."""
incidents: list[HermesIncident] = []
for record in records:
for rule in rules:
incident = rule.evaluate(record)
if incident is not None:
incidents.append(incident)
return incidents
__all__ = [
"PatternRule",
"RepeatRule",
"default_pattern_rules",
"evaluate_all",
]