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

396 lines
15 KiB
Python

"""Agent-callable Hermes log inspection tool.
Exposes :func:`get_hermes_logs` to the investigation planner so the
agent can read its own ``~/.hermes/logs/errors.log`` (or any file it's
configured to watch) without re-implementing the polling logic. The
heavy lifting lives in :mod:`integrations.hermes.poller`; this module is the
thin presentation layer:
* declares the tool metadata, input schema, and use-cases
* serialises :class:`HermesLogPoll` into JSON-safe primitives
* opportunistically computes incident summaries for the planner
Two modes:
``op="scan"``
One-shot read of the most recent ``N`` log lines. Useful for
"what's been going wrong?" questions where the agent wants a
snapshot. Returns lines, parsed records, and any incidents the
classifier would emit on this window.
``op="tail"``
Incremental, cursor-driven poll. The caller passes a ``cursor``
token returned by a previous call; the tool yields only lines
that appeared since. Rotation- and truncation-safe. This is the
efficient mode for repeated polling — bandwidth is O(new lines)
not O(file size).
**Known limitation — classifier state is not persisted between
calls.** Each invocation constructs a fresh
:class:`~integrations.hermes.classifier.IncidentClassifier`, so burst-window
counts and traceback-continuation state reset on every tool call.
Multi-call burst detection will therefore under-count incidents
whose constituent records span two separate ``tail`` invocations.
The production :class:`~integrations.hermes.agent.HermesAgent` avoids this by
keeping a single long-lived classifier; agents that need accurate
cross-poll burst detection should use the watch command instead of
repeated ``tail`` calls.
"""
from __future__ import annotations
import os
from dataclasses import replace
from pathlib import Path
from typing import Any
from core.tool_framework.tool_decorator import tool
from integrations.hermes.availability import hermes_available_or_backend
from integrations.hermes.classifier import IncidentClassifier
from integrations.hermes.incident import HermesIncident, LogLevel, LogRecord
from integrations.hermes.poller import HermesLogCursor, HermesLogPoll, poll_hermes_logs
# Default location of Hermes' own error log. The agent tool resolves
# this lazily so a non-default ``$HERMES_HOME`` is respected without
# import-time side effects.
_ENV_LOG_PATH: str = "HERMES_LOG_PATH"
_DEFAULT_LOG_RELATIVE: tuple[str, ...] = (".hermes", "logs", "errors.log")
# Cap how many records the tool will serialise into a single
# response. The poller has its own byte budget; this is the
# token-budget guard for the LLM's context.
_MAX_RECORDS_PER_CALL: int = 200
_MAX_INCIDENTS_PER_CALL: int = 50
def _default_log_path() -> Path:
"""Resolve the Hermes log file path with environment override."""
override = os.environ.get(_ENV_LOG_PATH, "").strip()
if override:
return Path(override).expanduser()
return Path.home().joinpath(*_DEFAULT_LOG_RELATIVE)
def _allowed_log_dirs() -> tuple[Path, ...]:
"""Directories the tool is permitted to read from.
By default this is ``~/.hermes`` — the directory tree that the
Hermes agent writes to. When ``HERMES_LOG_PATH`` is set to a path
outside that tree the env-var parent is added automatically, so
operators with non-standard log locations don't need extra config.
"""
dirs: list[Path] = [Path.home() / ".hermes"]
override = os.environ.get(_ENV_LOG_PATH, "").strip()
if override:
dirs.append(Path(override).expanduser().resolve(strict=False).parent)
return tuple(dirs)
def _validate_log_path(path: Path) -> None:
"""Raise ``ValueError`` if *path* is outside the allowed log directories.
The ``log_path`` parameter is LLM-supplied and therefore untrusted.
Without this guard a crafted call could read arbitrary files (e.g.
``/etc/shadow``) by passing them as ``log_path``. We resolve to an
absolute path with no symlink traversal (``strict=False`` so
missing files are still validated) before comparing.
"""
resolved = path.expanduser().resolve(strict=False)
for allowed in _allowed_log_dirs():
try:
resolved.relative_to(allowed.resolve(strict=False))
return
except ValueError:
continue
raise ValueError(
f"log_path {str(path)!r} is outside the permitted log directories; "
"set HERMES_LOG_PATH to allow a custom location"
)
def _serialise_record(record: LogRecord) -> dict[str, Any]:
return {
"timestamp": record.timestamp.isoformat() if not record.is_continuation else None,
"level": record.level.value,
"logger": record.logger,
"message": record.message,
"raw": record.raw,
"run_id": record.run_id,
"is_continuation": record.is_continuation,
}
def _serialise_incident(incident: HermesIncident) -> dict[str, Any]:
return {
"rule": incident.rule,
"severity": incident.severity.value,
"title": incident.title,
"logger": incident.logger,
"fingerprint": incident.fingerprint,
"detected_at": incident.detected_at.isoformat(),
"record_count": len(incident.records),
"run_id": incident.run_id,
}
def _parse_level_filter(levels: list[str] | None) -> frozenset[LogLevel] | None:
if not levels:
return None
parsed: set[LogLevel] = set()
for raw in levels:
try:
parsed.add(LogLevel(raw.strip().upper()))
except ValueError as exc:
raise ValueError(
f"unknown log level {raw!r}; expected one of "
+ ", ".join(level.value for level in LogLevel)
) from exc
return frozenset(parsed)
def _serialise_poll(
poll: HermesLogPoll,
*,
max_records: int,
max_incidents: int,
keep_most_recent: bool = False,
) -> dict[str, Any]:
if keep_most_recent and len(poll.records) > max_records:
# 'scan' wants the most recent records — drop from the front,
# not the tail, so the response shows what just happened.
records = poll.records[-max_records:]
else:
records = poll.records[:max_records]
truncated_in_response = len(poll.records) - len(records)
incidents = poll.incidents[:max_incidents]
# In scan mode the seek-back heuristic may overshoot and yield more
# records than ``tail_lines``; the excess was already dropped above
# via ``keep_most_recent``. Setting ``has_more`` based solely on
# that overshoot would mislead the caller into an unnecessary
# follow-up tail call. Suppress it when we are in scan mode (i.e.
# ``keep_most_recent``) and the only driver is
# ``truncated_response_records`` from the overshoot.
if keep_most_recent:
has_more = poll.truncated_lines > 0 or poll.rotation_detected
else:
has_more = poll.truncated_lines > 0 or truncated_in_response > 0 or poll.rotation_detected
return {
"cursor": poll.cursor.to_token(),
"rotation_detected": poll.rotation_detected,
# ``truncated_lines`` is what the poller dropped under its
# own cap; ``truncated_response_records`` is what we dropped
# from THIS response under our token-budget cap. Surface both
# so the agent knows it should re-poll with the cursor.
"truncated_lines": poll.truncated_lines,
"truncated_response_records": truncated_in_response,
"parsed_line_count": poll.parsed_line_count,
"records": [_serialise_record(r) for r in records],
"incidents": [_serialise_incident(i) for i in incidents],
"has_more": has_more,
}
@tool(
name="get_hermes_logs",
display_name="Hermes log poll",
source="hermes",
description=(
"Read Hermes Agent's own ~/.hermes/logs/errors.log (or another "
"Hermes log file) incrementally. Use op='scan' for a one-shot "
"read of the last N records or op='tail' for cursor-driven "
"incremental polling that only returns lines new since the "
"previous call. Records are parsed and any incidents the "
"classifier would emit on this window are included."
),
use_cases=[
"Investigating why the agent itself is failing (gateway crashes, "
+ "auth bypass, polling conflicts)",
"Following a Hermes log live during an active incident without "
+ "re-reading the entire file on every call",
"Surfacing structured incidents (error_severity, traceback, "
+ "warning_burst) from a slice of recent log activity",
],
tags=("safe", "fast", "no-credentials"),
is_available=hermes_available_or_backend,
input_schema={
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": ["scan", "tail"],
"default": "scan",
"description": (
"'scan' for a one-shot read; 'tail' for cursor-driven incremental polling."
),
},
"log_path": {
"type": "string",
"description": (
"Path to the Hermes log file. Defaults to "
"$HERMES_LOG_PATH or ~/.hermes/logs/errors.log."
),
},
"cursor": {
"type": "string",
"description": (
"Opaque resume token returned by a previous call. "
"Required for op='tail' on the second+ call. "
"Ignored for op='scan'."
),
},
"tail_lines": {
"type": "integer",
"default": 200,
"minimum": 1,
"maximum": _MAX_RECORDS_PER_CALL,
"description": (
"For op='scan': how many recent records to return. Ignored for op='tail'."
),
},
"max_records": {
"type": "integer",
"default": _MAX_RECORDS_PER_CALL,
"minimum": 1,
"maximum": _MAX_RECORDS_PER_CALL,
"description": (
"Upper bound on records included in the response. "
"Hits truncated_response_records when exceeded."
),
},
"levels": {
"type": "array",
"items": {
"type": "string",
"enum": [level.value for level in LogLevel],
},
"description": (
"Only return records at these levels. The "
"classifier still observes filtered records so "
"traceback continuations and warning-burst windows "
"remain accurate."
),
},
},
"required": [],
},
)
def get_hermes_logs(
op: str = "scan",
log_path: str | None = None,
cursor: str | None = None,
tail_lines: int = 200,
max_records: int = _MAX_RECORDS_PER_CALL,
levels: list[str] | None = None,
) -> dict[str, Any]:
"""Read Hermes log activity. See module docstring for op semantics."""
if op not in {"scan", "tail"}:
return {
"error": f"unknown op {op!r}; expected 'scan' or 'tail'",
"records": [],
"incidents": [],
}
try:
level_filter = _parse_level_filter(levels)
except ValueError as exc:
return {"error": str(exc), "records": [], "incidents": []}
resolved_path = Path(log_path).expanduser() if log_path else _default_log_path()
try:
_validate_log_path(resolved_path)
except ValueError as exc:
return {"error": str(exc), "records": [], "incidents": []}
resolved_cursor: HermesLogCursor | None
if op == "scan":
# 'scan' always rewinds to the end of the file minus tail_lines
# worth of bytes (estimated at 480 bytes/line — aligns with the
# seek-back heuristic in ``_seek_back_n_lines``, below).
# The poller then reads forward;
# we cap to tail_lines in the response.
bounded_tail = max(1, min(tail_lines, _MAX_RECORDS_PER_CALL))
resolved_cursor = _seek_back_n_lines(resolved_path, bounded_tail)
bounded_max = min(max_records, bounded_tail)
elif cursor:
try:
resolved_cursor = HermesLogCursor.from_token(cursor)
# Tokens are LLM-round-tripped; reject crafted paths that
# do not match the log file this invocation is configured to read.
resolved_cursor.validate_expected_log_path(resolved_path)
except ValueError as exc:
return {"error": str(exc), "records": [], "incidents": []}
bounded_max = min(max_records, _MAX_RECORDS_PER_CALL)
else:
# op='tail' without a cursor: anchor at end-of-file so the
# very first tail call doesn't replay the entire backlog.
resolved_cursor = HermesLogCursor.at_end(resolved_path)
bounded_max = min(max_records, _MAX_RECORDS_PER_CALL)
classifier = IncidentClassifier()
try:
poll = poll_hermes_logs(
resolved_path,
resolved_cursor,
max_lines=_MAX_RECORDS_PER_CALL,
classifier=classifier,
level_filter=level_filter,
)
except PermissionError as exc:
return {
"error": f"permission denied reading {resolved_path}: {exc}",
"records": [],
"incidents": [],
}
flushed = tuple(classifier.flush())
if flushed:
poll = replace(poll, incidents=poll.incidents + flushed)
return _serialise_poll(
poll,
max_records=bounded_max,
max_incidents=_MAX_INCIDENTS_PER_CALL,
keep_most_recent=(op == "scan"),
)
def _seek_back_n_lines(path: Path, n: int) -> HermesLogCursor:
"""Best-effort cursor that points roughly ``n`` lines before EOF.
We can't cheaply count lines from the end of a file without
reading it, so we estimate a generous 480 bytes per line (Hermes
records often include long tracebacks). For small files the
estimate may exceed the file size — in that case we just read
from offset 0 so the response naturally contains the last N
records of the available log.
"""
bytes_per_line_estimate = 480
try:
stat = path.stat()
except (FileNotFoundError, PermissionError):
return HermesLogCursor.at_start(path)
needed_bytes = n * bytes_per_line_estimate
offset = max(0, stat.st_size - needed_bytes)
# If we'd land mid-line, snap forward to the next newline so the
# parser doesn't see a truncated first record. Skipped when we're
# already at offset 0 (start of file is always a clean line edge).
if offset > 0:
offset = _next_line_offset(path, offset)
return HermesLogCursor(path=str(path), device=stat.st_dev, inode=stat.st_ino, offset=offset)
def _next_line_offset(path: Path, offset: int) -> int:
try:
with path.open("rb") as handle:
handle.seek(offset)
handle.readline() # advance to start of next line
return handle.tell()
except OSError:
return offset
__all__ = ["get_hermes_logs"]