Compare commits
20 Commits
feat/provi
...
feat/langf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8c2c77be6 | ||
|
|
8081425a1c | ||
|
|
ec8243fe2a | ||
|
|
3d67364b8f | ||
|
|
38a6bada92 | ||
|
|
6c70ac8eef | ||
|
|
d497387cec | ||
|
|
32d4048c6b | ||
|
|
1eab5960f0 | ||
|
|
74a4832b74 | ||
|
|
fbbcfa24c5 | ||
|
|
f223346eb7 | ||
|
|
57f8cf00e9 | ||
|
|
6649e7e746 | ||
|
|
32b78578e0 | ||
|
|
6769a0aece | ||
|
|
d7528d43ac | ||
|
|
a7cdd4133c | ||
|
|
461ef88705 | ||
|
|
12d745bd7e |
@@ -202,19 +202,33 @@ def _forbids_sampling_params(model: str) -> bool:
|
||||
|
||||
|
||||
# Beta headers for enhanced features (sent with ALL auth types).
|
||||
# As of Opus 4.7 (2026-04-16), both of these are GA on Claude 4.6+ — the
|
||||
# As of Opus 4.7 (2026-04-16), the first two are GA on Claude 4.6+ — the
|
||||
# beta headers are still accepted (harmless no-op) but not required. Kept
|
||||
# here so older Claude (4.5, 4.1) + third-party Anthropic-compat endpoints
|
||||
# that still gate on the headers continue to get the enhanced features.
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models.
|
||||
#
|
||||
# ``context-1m-2025-08-07`` unlocks the 1M context window on Claude Opus 4.6/4.7
|
||||
# and Sonnet 4.6 when served via AWS Bedrock or Azure AI Foundry. 1M is GA on
|
||||
# native Anthropic (api.anthropic.com) for Opus 4.6+, but Bedrock/Azure still
|
||||
# gate it behind this beta header as of 2026-04 — without it Bedrock caps Opus
|
||||
# at 200K even though model_metadata.py advertises 1M. The header is a harmless
|
||||
# no-op on endpoints where 1M is GA.
|
||||
#
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models or once
|
||||
# Bedrock/Azure promote 1M to GA.
|
||||
_COMMON_BETAS = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
]
|
||||
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
|
||||
# the fine-grained tool streaming beta is present. Omit it so tool calls
|
||||
# fall back to the provider's default response path.
|
||||
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||||
# 1M context beta — see comment on _COMMON_BETAS above. Stripped for
|
||||
# Bearer-auth (MiniMax) endpoints since they host their own models and
|
||||
# unknown Anthropic beta headers risk request rejection.
|
||||
_CONTEXT_1M_BETA = "context-1m-2025-08-07"
|
||||
|
||||
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
|
||||
# significantly higher output token throughput on Opus 4.6 (~2.5x).
|
||||
@@ -357,9 +371,14 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
|
||||
tool-use message triggers a connection error. Strip that beta for
|
||||
Bearer-auth endpoints while keeping all other betas intact.
|
||||
|
||||
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
|
||||
endpoints — MiniMax hosts its own models, not Claude, so the header is
|
||||
irrelevant at best and risks request rejection at worst.
|
||||
"""
|
||||
if _requires_bearer_auth(base_url):
|
||||
return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA]
|
||||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||||
return [b for b in _COMMON_BETAS if b not in _stripped]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
@@ -456,6 +475,13 @@ def build_anthropic_bedrock_client(region: str):
|
||||
Claude feature parity: prompt caching, thinking budgets, adaptive
|
||||
thinking, fast mode — features not available via the Converse API.
|
||||
|
||||
Attaches the common Anthropic beta headers as client-level defaults so
|
||||
that Bedrock-hosted Claude models get the same enhanced features as
|
||||
native Anthropic. The ``context-1m-2025-08-07`` beta in particular
|
||||
unlocks the 1M context window for Opus 4.6/4.7 on Bedrock — without
|
||||
it, Bedrock caps these models at 200K even though the Anthropic API
|
||||
serves them with 1M natively.
|
||||
|
||||
Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).
|
||||
"""
|
||||
if _anthropic_sdk is None:
|
||||
@@ -473,6 +499,7 @@ def build_anthropic_bedrock_client(region: str):
|
||||
return _anthropic_sdk.AnthropicBedrock(
|
||||
aws_region=region,
|
||||
timeout=Timeout(timeout=900.0, connect=10.0),
|
||||
default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -56,8 +56,12 @@ _SENSITIVE_BODY_KEYS = frozenset({
|
||||
})
|
||||
|
||||
# Snapshot at import time so runtime env mutations (e.g. LLM-generated
|
||||
# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction mid-session.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off")
|
||||
# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction
|
||||
# mid-session. OFF by default — user must opt in via
|
||||
# `security.redact_secrets: true` in config.yaml (bridged to this env var
|
||||
# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true`
|
||||
# in ~/.hermes/.env.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# Known API key prefixes -- match the prefix + contiguous token chars
|
||||
_PREFIX_PATTERNS = [
|
||||
@@ -257,7 +261,7 @@ def redact_sensitive_text(text: str) -> str:
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Disabled when security.redact_secrets is false in config.yaml.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
@@ -307,9 +307,14 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
|
||||
|
||||
Returns ``(session_kwargs, request_kwargs)`` where:
|
||||
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
|
||||
- HTTP → ``({}, {"proxy": url})``
|
||||
- None → ``({}, {})``
|
||||
- With aiohttp-socks → ``({"connector": ProxyConnector(...)}, {})``
|
||||
for *all* proxy schemes (SOCKS **and** HTTP/HTTPS).
|
||||
- HTTP without aiohttp-socks → ``({}, {"proxy": url})``.
|
||||
- None → ``({}, {})``.
|
||||
|
||||
Prefer the connector path: it works transparently with libraries
|
||||
(like mautrix) that call ``session.request()`` without forwarding
|
||||
per-request ``proxy=`` kwargs.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -320,20 +325,20 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""
|
||||
if not proxy_url:
|
||||
return {}, {}
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return {}, {}
|
||||
return {}, {"proxy": proxy_url}
|
||||
return {}, {"proxy": proxy_url}
|
||||
|
||||
|
||||
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
|
||||
@@ -2427,11 +2432,15 @@ class BasePlatformAdapter(ABC):
|
||||
# Send the text portion
|
||||
if text_content:
|
||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
||||
# Build send metadata: thread_id + mention target for platforms that need it
|
||||
send_metadata = dict(_thread_metadata) if _thread_metadata else {}
|
||||
if event.source.user_id:
|
||||
send_metadata["mention_user_id"] = event.source.user_id
|
||||
result = await self._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=text_content,
|
||||
reply_to=event.message_id,
|
||||
metadata=_thread_metadata,
|
||||
metadata=send_metadata,
|
||||
)
|
||||
_record_delivery(result)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Environment variables:
|
||||
MATRIX_PASSWORD Password (alternative to access token)
|
||||
MATRIX_ENCRYPTION Set "true" to enable E2EE
|
||||
MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts
|
||||
MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic
|
||||
MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
|
||||
MATRIX_HOME_ROOM Room ID for cron/notification delivery
|
||||
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
|
||||
@@ -18,6 +19,7 @@ Environment variables:
|
||||
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
|
||||
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
|
||||
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
|
||||
MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
|
||||
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
|
||||
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
|
||||
"""
|
||||
@@ -30,6 +32,8 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from html import escape as _html_escape
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Set
|
||||
@@ -95,11 +99,25 @@ from gateway.platforms.base import (
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
resolve_proxy_url,
|
||||
proxy_kwargs_for_aiohttp,
|
||||
)
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MatrixApprovalPrompt:
|
||||
"""Tracks a pending Matrix reaction-based exec approval prompt."""
|
||||
|
||||
def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False):
|
||||
self.session_key = session_key
|
||||
self.chat_id = chat_id
|
||||
self.message_id = message_id
|
||||
self.resolved = resolved
|
||||
self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id
|
||||
|
||||
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
||||
# but clients render poorly above this).
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
@@ -114,11 +132,85 @@ _CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
|
||||
_OUTBOUND_MENTION_RE = re.compile(
|
||||
r"(?<![\w/])(@[0-9A-Za-z._=/-]+:[0-9A-Za-z.-]+(?::\d+)?)"
|
||||
)
|
||||
|
||||
_E2EE_INSTALL_HINT = (
|
||||
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
|
||||
)
|
||||
|
||||
_MATRIX_IMAGE_FILENAME_EXTS = frozenset({
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
".avif",
|
||||
})
|
||||
|
||||
|
||||
def _looks_like_matrix_image_filename(text: str) -> bool:
|
||||
"""Return True when Matrix image body text is probably just a transport filename.
|
||||
|
||||
Matrix ``m.image`` events commonly populate ``content.body`` with the uploaded
|
||||
filename when the user did not add a caption. Treating that raw filename as
|
||||
user-authored text confuses downstream vision enrichment.
|
||||
"""
|
||||
candidate = str(text or "").strip()
|
||||
if not candidate or "\n" in candidate or candidate.endswith("/"):
|
||||
return False
|
||||
|
||||
name = Path(candidate).name
|
||||
if not name or name != candidate:
|
||||
return False
|
||||
|
||||
suffix = Path(name).suffix.lower()
|
||||
if not suffix:
|
||||
return False
|
||||
|
||||
guessed_type, _ = mimetypes.guess_type(name)
|
||||
if guessed_type and guessed_type.startswith("image/"):
|
||||
return True
|
||||
return suffix in _MATRIX_IMAGE_FILENAME_EXTS
|
||||
|
||||
|
||||
def _create_matrix_session(proxy_url: str | None):
|
||||
"""Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests.
|
||||
|
||||
mautrix's ``HTTPAPI._send()`` calls ``session.request()`` without forwarding
|
||||
per-request ``proxy=`` kwargs. For HTTP(S) proxies we use aiohttp's native
|
||||
``proxy=`` session parameter which sets a default for every request. For SOCKS
|
||||
we use ``aiohttp_socks.ProxyConnector`` (connector-level).
|
||||
When no proxy is configured we enable ``trust_env`` so standard env vars
|
||||
(``HTTP_PROXY`` / ``HTTPS_PROXY``) are honoured automatically.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
if not proxy_url:
|
||||
return aiohttp.ClientSession(trust_env=True)
|
||||
|
||||
if proxy_url.split("://")[0].lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
return aiohttp.ClientSession(
|
||||
connector=ProxyConnector.from_url(proxy_url, rdns=True),
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return aiohttp.ClientSession(trust_env=True)
|
||||
|
||||
return aiohttp.ClientSession(proxy=proxy_url)
|
||||
|
||||
|
||||
def _check_e2ee_deps() -> bool:
|
||||
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
|
||||
@@ -260,6 +352,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self._dm_auto_thread: bool = os.getenv(
|
||||
"MATRIX_DM_AUTO_THREAD", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
self._dm_mention_threads: bool = os.getenv(
|
||||
"MATRIX_DM_MENTION_THREADS", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
@@ -270,6 +365,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
).lower() not in ("false", "0", "no")
|
||||
self._pending_reactions: dict[tuple[str, str], str] = {}
|
||||
|
||||
# Proxy support — resolve once at init, reuse for all HTTP traffic.
|
||||
self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
|
||||
if self._proxy_url:
|
||||
logger.info("Matrix: proxy configured — %s", self._proxy_url)
|
||||
|
||||
# Text batching: merge rapid successive messages (Telegram-style).
|
||||
# Matrix clients split long messages around 4000 chars.
|
||||
self._text_batch_delay_seconds = float(
|
||||
@@ -281,6 +381,18 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
# Matrix reaction-based dangerous command approvals.
|
||||
self._approval_reaction_map = {
|
||||
"✅": "once",
|
||||
"❎": "deny",
|
||||
}
|
||||
self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {}
|
||||
self._approval_prompt_by_session: Dict[str, str] = {}
|
||||
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
|
||||
self._allowed_user_ids: Set[str] = {
|
||||
u.strip() for u in allowed_users_raw.split(",") if u.strip()
|
||||
}
|
||||
|
||||
def _is_duplicate_event(self, event_id) -> bool:
|
||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||
if not event_id:
|
||||
@@ -326,7 +438,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc)
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc, exc_info=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -342,6 +454,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.error(
|
||||
"Matrix: cannot verify device keys on server: %s — refusing E2EE",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -356,7 +469,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
await olm.share_keys()
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc)
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc, exc_info=True)
|
||||
return False
|
||||
return await self._reverify_keys_after_upload(client, local_ed25519)
|
||||
|
||||
@@ -396,6 +509,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"Try generating a new access token to get a fresh device.",
|
||||
client.device_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return await self._reverify_keys_after_upload(client, local_ed25519)
|
||||
@@ -420,9 +534,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create the HTTP API layer.
|
||||
client_session = _create_matrix_session(self._proxy_url)
|
||||
api = HTTPAPI(
|
||||
base_url=self._homeserver,
|
||||
token=self._access_token or "",
|
||||
client_session=client_session,
|
||||
)
|
||||
|
||||
# Create the client.
|
||||
@@ -465,6 +581,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.error(
|
||||
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await api.session.close()
|
||||
return False
|
||||
@@ -607,6 +724,44 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.warning(
|
||||
"Matrix: recovery key verification failed: %s", exc
|
||||
)
|
||||
else:
|
||||
# No recovery key — bootstrap cross-signing if the bot
|
||||
# has none yet. Without this, Element shows "Encrypted
|
||||
# by a device not verified by its owner" on every
|
||||
# message from this bot, indefinitely. mautrix's
|
||||
# generate_recovery_key does the full flow: generates
|
||||
# MSK/SSK/USK, uploads private keys to SSSS, publishes
|
||||
# public keys to the homeserver, and signs the current
|
||||
# device with the new SSK. Some homeservers require UIA
|
||||
# for /keys/device_signing/upload — those will need an
|
||||
# alternate path; Continuwuity and Synapse-with-shared-
|
||||
# secret accept the unauthenticated upload.
|
||||
try:
|
||||
own_xsign = await olm.get_own_cross_signing_public_keys()
|
||||
except Exception as exc:
|
||||
own_xsign = None
|
||||
logger.warning(
|
||||
"Matrix: cross-signing key lookup failed: %s", exc
|
||||
)
|
||||
if own_xsign is None:
|
||||
try:
|
||||
new_recovery_key = await olm.generate_recovery_key()
|
||||
logger.warning(
|
||||
"Matrix: bootstrapped cross-signing for %s. "
|
||||
"SAVE THIS RECOVERY KEY — set "
|
||||
"MATRIX_RECOVERY_KEY for future restarts so "
|
||||
"the bot can re-sign its device after key "
|
||||
"rotation: %s",
|
||||
client.mxid,
|
||||
new_recovery_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Matrix: cross-signing bootstrap failed "
|
||||
"(non-fatal — Element will show 'not "
|
||||
"verified by its owner'): %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
client.crypto = olm
|
||||
logger.info(
|
||||
@@ -664,6 +819,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: initial sync event dispatch error: %s", exc)
|
||||
await self._join_pending_invites(sync_data)
|
||||
else:
|
||||
logger.warning(
|
||||
"Matrix: initial sync returned unexpected type %s",
|
||||
@@ -723,21 +879,32 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if not content:
|
||||
return SendResult(success=True)
|
||||
|
||||
mention_user_id = (metadata or {}).get("mention_user_id")
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
|
||||
|
||||
last_event_id = None
|
||||
for chunk in chunks:
|
||||
msg_content: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": chunk,
|
||||
}
|
||||
for i, chunk in enumerate(chunks):
|
||||
msg_content = self._build_text_message_content(chunk)
|
||||
|
||||
# Convert markdown to HTML for rich rendering.
|
||||
html = self._markdown_to_html(chunk)
|
||||
if html and html != chunk:
|
||||
# Append @mention pill to the last chunk for push notifications
|
||||
# in muted rooms (mention-only mode).
|
||||
if mention_user_id and i == len(chunks) - 1:
|
||||
mention_html = (
|
||||
f'<a href="https://matrix.to/#/{mention_user_id}">'
|
||||
f"{mention_user_id}</a>"
|
||||
)
|
||||
msg_content["body"] = chunk + f" @{mention_user_id}"
|
||||
base_html = msg_content.get("formatted_body", chunk)
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
msg_content["formatted_body"] = base_html + " " + mention_html
|
||||
# m.mentions for MSC3952 push reliability.
|
||||
existing_mentions = msg_content.get("m.mentions", {}).get("user_ids", [])
|
||||
if mention_user_id not in existing_mentions:
|
||||
msg_content["m.mentions"] = {
|
||||
"user_ids": existing_mentions + [mention_user_id]
|
||||
}
|
||||
|
||||
# Reply-to support.
|
||||
if reply_to:
|
||||
@@ -844,25 +1011,21 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Edit an existing message (via m.replace)."""
|
||||
|
||||
formatted = self.format_message(content)
|
||||
new_content = self._build_text_message_content(formatted)
|
||||
msg_content: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": f"* {formatted}",
|
||||
"m.new_content": {
|
||||
"msgtype": "m.text",
|
||||
"body": formatted,
|
||||
},
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
},
|
||||
"m.new_content": new_content,
|
||||
}
|
||||
|
||||
html = self._markdown_to_html(formatted)
|
||||
if html and html != formatted:
|
||||
msg_content["m.new_content"]["format"] = "org.matrix.custom.html"
|
||||
msg_content["m.new_content"]["formatted_body"] = html
|
||||
if "m.mentions" in new_content:
|
||||
msg_content["m.mentions"] = new_content["m.mentions"]
|
||||
if "formatted_body" in new_content:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = f"* {html}"
|
||||
msg_content["formatted_body"] = f'* {new_content["formatted_body"]}'
|
||||
msg_content["m.relates_to"] = {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
}
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -895,10 +1058,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Try aiohttp first (always available), fall back to httpx
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
|
||||
async with _aiohttp.ClientSession(trust_env=True) as http:
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
|
||||
async with _aiohttp.ClientSession(**_sess_kw) as http:
|
||||
async with http.get(
|
||||
image_url, timeout=_aiohttp.ClientTimeout(total=30)
|
||||
image_url,
|
||||
timeout=_aiohttp.ClientTimeout(total=30),
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.read()
|
||||
@@ -908,8 +1073,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except ImportError:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
_httpx_kw: dict = {}
|
||||
if self._proxy_url:
|
||||
_httpx_kw["proxy"] = self._proxy_url
|
||||
async with httpx.AsyncClient(**_httpx_kw) as http:
|
||||
resp = await http.get(image_url, follow_redirects=True, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
@@ -984,6 +1151,56 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
chat_id, video_path, "m.video", caption, reply_to, metadata=metadata
|
||||
)
|
||||
|
||||
async def send_exec_approval(
|
||||
self,
|
||||
chat_id: str,
|
||||
command: str,
|
||||
session_key: str,
|
||||
description: str = "dangerous command",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> SendResult:
|
||||
"""Send a reaction-based exec approval prompt for Matrix."""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
|
||||
text = (
|
||||
"⚠️ **Dangerous command requires approval**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {description}\n\n"
|
||||
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||
"You can also click the reaction to approve:\n"
|
||||
"✅ = /approve\n"
|
||||
"❎ = /deny"
|
||||
)
|
||||
|
||||
result = await self.send(chat_id, text, metadata=metadata)
|
||||
if not result.success or not result.message_id:
|
||||
return result
|
||||
|
||||
prompt = _MatrixApprovalPrompt(
|
||||
session_key=session_key,
|
||||
chat_id=chat_id,
|
||||
message_id=result.message_id,
|
||||
)
|
||||
old_event = self._approval_prompt_by_session.get(session_key)
|
||||
if old_event:
|
||||
self._approval_prompts_by_event.pop(old_event, None)
|
||||
self._approval_prompts_by_event[result.message_id] = prompt
|
||||
self._approval_prompt_by_session[session_key] = result.message_id
|
||||
|
||||
for emoji in ("✅", "❎"):
|
||||
try:
|
||||
reaction_result = await self._send_reaction(chat_id, result.message_id, emoji)
|
||||
# Save the bot's reaction event_id for later cleanup
|
||||
if reaction_result:
|
||||
prompt.bot_reaction_events[emoji] = str(reaction_result)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to add approval reaction %s: %s", emoji, exc)
|
||||
|
||||
return result
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Pass-through — Matrix supports standard Markdown natively."""
|
||||
# Strip image markdown; media is uploaded separately.
|
||||
@@ -1115,9 +1332,15 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
next_batch = await client.sync_store.get_next_batch()
|
||||
while not self._closing:
|
||||
try:
|
||||
sync_data = await client.sync(
|
||||
since=next_batch,
|
||||
timeout=30000,
|
||||
# Wrap in asyncio.wait_for to guard against TCP-level hangs
|
||||
# that the Matrix long-poll timeout cannot catch. Long-poll
|
||||
# is 30s, so 45s gives 15s slack for network drain.
|
||||
sync_data = await asyncio.wait_for(
|
||||
client.sync(
|
||||
since=next_batch,
|
||||
timeout=30000,
|
||||
),
|
||||
timeout=45.0,
|
||||
)
|
||||
|
||||
# nio returns SyncError objects (not exceptions) for auth
|
||||
@@ -1153,6 +1376,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: sync event dispatch error: %s", exc)
|
||||
await self._join_pending_invites(sync_data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
@@ -1239,6 +1463,15 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
room_id = str(getattr(event, "room_id", ""))
|
||||
sender = str(getattr(event, "sender", ""))
|
||||
|
||||
# Diagnostic: confirm the callback is firing at all when DEBUG is on.
|
||||
# Helps users troubleshoot silent inbound issues like #5819, #7914, #12614.
|
||||
logger.debug(
|
||||
"Matrix: callback fired — event %s from %s in %s",
|
||||
getattr(event, "event_id", "?"),
|
||||
sender,
|
||||
room_id,
|
||||
)
|
||||
|
||||
# Ignore own messages (case-insensitive; also drops when our own
|
||||
# user_id hasn't been resolved yet — see _is_self_sender docstring
|
||||
# and issue #15763).
|
||||
@@ -1350,6 +1583,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
in_bot_thread = bool(thread_id and thread_id in self._threads)
|
||||
if self._require_mention and not is_free_room and not in_bot_thread:
|
||||
if not is_mentioned:
|
||||
logger.debug(
|
||||
"Matrix: ignoring message %s in %s — no @mention "
|
||||
"(set MATRIX_REQUIRE_MENTION=false to disable)",
|
||||
event_id,
|
||||
room_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# DM mention-thread.
|
||||
@@ -1362,7 +1601,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
body = self._strip_mention(body)
|
||||
|
||||
# Auto-thread.
|
||||
if not is_dm and not thread_id and self._auto_thread:
|
||||
if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)):
|
||||
thread_id = event_id
|
||||
self._threads.mark(thread_id)
|
||||
|
||||
@@ -1604,6 +1843,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return
|
||||
body, is_dm, chat_type, thread_id, display_name, source = ctx
|
||||
|
||||
if msgtype == "m.image" and _looks_like_matrix_image_filename(body):
|
||||
body = ""
|
||||
|
||||
allow_http_fallback = bool(http_url) and not is_encrypted_media
|
||||
media_urls = (
|
||||
[cached_path]
|
||||
@@ -1633,13 +1875,35 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"Matrix: invited to %s — joining",
|
||||
room_id,
|
||||
)
|
||||
await self._join_room_by_id(room_id)
|
||||
|
||||
async def _join_room_by_id(self, room_id: str) -> bool:
|
||||
"""Join a room by ID and refresh local caches on success."""
|
||||
if not room_id:
|
||||
return False
|
||||
if room_id in self._joined_rooms:
|
||||
return True
|
||||
try:
|
||||
await self._client.join_room(RoomID(room_id))
|
||||
self._joined_rooms.add(room_id)
|
||||
logger.info("Matrix: joined %s", room_id)
|
||||
await self._refresh_dm_cache()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: error joining %s: %s", room_id, exc)
|
||||
return False
|
||||
|
||||
async def _join_pending_invites(self, sync_data: Dict[str, Any]) -> None:
|
||||
"""Join rooms still present in rooms.invite after sync processing."""
|
||||
rooms = sync_data.get("rooms", {}) if isinstance(sync_data, dict) else {}
|
||||
invites = rooms.get("invite", {})
|
||||
if not isinstance(invites, dict):
|
||||
return
|
||||
for room_id in invites:
|
||||
if room_id in self._joined_rooms:
|
||||
continue
|
||||
logger.info("Matrix: reconciling pending invite for %s", room_id)
|
||||
await self._join_room_by_id(str(room_id))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reactions (send, receive, processing lifecycle)
|
||||
@@ -1754,6 +2018,51 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
room_id,
|
||||
)
|
||||
|
||||
# Check if this reaction resolves a pending approval prompt.
|
||||
prompt = self._approval_prompts_by_event.get(reacts_to)
|
||||
if prompt and not prompt.resolved:
|
||||
if room_id != prompt.chat_id:
|
||||
return
|
||||
if self._allowed_user_ids and sender not in self._allowed_user_ids:
|
||||
logger.info(
|
||||
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
|
||||
sender, reacts_to,
|
||||
)
|
||||
return
|
||||
choice = self._approval_reaction_map.get(key)
|
||||
if not choice:
|
||||
return
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
|
||||
count = resolve_gateway_approval(prompt.session_key, choice)
|
||||
if count:
|
||||
prompt.resolved = True
|
||||
self._approval_prompts_by_event.pop(reacts_to, None)
|
||||
self._approval_prompt_by_session.pop(prompt.session_key, None)
|
||||
logger.info(
|
||||
"Matrix reaction resolved %d approval(s) for session %s "
|
||||
"(choice=%s, user=%s)",
|
||||
count, prompt.session_key, choice, sender,
|
||||
)
|
||||
# Redact bot's seed reactions, leaving only the user's
|
||||
await self._redact_bot_approval_reactions(room_id, prompt)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc)
|
||||
|
||||
async def _redact_bot_approval_reactions(
|
||||
self,
|
||||
room_id: str,
|
||||
prompt: "_MatrixApprovalPrompt",
|
||||
) -> None:
|
||||
"""Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
|
||||
for emoji, evt_id in prompt.bot_reaction_events.items():
|
||||
try:
|
||||
await self.redact_message(room_id, evt_id, "approval resolved")
|
||||
logger.debug("Matrix: redacted bot reaction %s (%s)", emoji, evt_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to redact bot reaction %s: %s", emoji, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text message aggregation (handles Matrix client-side splits)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1979,11 +2288,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if not self._client or not text:
|
||||
return SendResult(success=False, error="No client or empty text")
|
||||
|
||||
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
||||
html = self._markdown_to_html(text)
|
||||
if html and html != text:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
msg_content = self._build_text_message_content(text, msgtype=msgtype)
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -2046,6 +2351,77 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Mention detection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_text_message_content(self, text: str, msgtype: str = "m.text") -> Dict[str, Any]:
|
||||
"""Build Matrix text content with HTML and outbound mention metadata."""
|
||||
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
||||
mention_user_ids = self._extract_outbound_mentions(text)
|
||||
if mention_user_ids:
|
||||
msg_content["m.mentions"] = {"user_ids": mention_user_ids}
|
||||
|
||||
html_source = self._inject_outbound_mention_links(text)
|
||||
html = self._markdown_to_html(html_source)
|
||||
if html and html != text:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
|
||||
return msg_content
|
||||
|
||||
def _extract_outbound_mentions(self, text: str) -> list[str]:
|
||||
"""Return unique Matrix user IDs mentioned in outbound text."""
|
||||
protected, _ = self._protect_outbound_mention_regions(text)
|
||||
seen: Set[str] = set()
|
||||
mentions: list[str] = []
|
||||
for match in _OUTBOUND_MENTION_RE.finditer(protected):
|
||||
user_id = match.group(1)
|
||||
if user_id not in seen:
|
||||
seen.add(user_id)
|
||||
mentions.append(user_id)
|
||||
return mentions
|
||||
|
||||
def _inject_outbound_mention_links(self, text: str) -> str:
|
||||
"""Wrap outbound Matrix mentions in markdown links outside code spans."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
protected, placeholders = self._protect_outbound_mention_regions(text)
|
||||
|
||||
linked = _OUTBOUND_MENTION_RE.sub(
|
||||
lambda match: f"[{match.group(1)}](https://matrix.to/#/{match.group(1)})",
|
||||
protected,
|
||||
)
|
||||
|
||||
for idx, original in enumerate(placeholders):
|
||||
linked = linked.replace(f"\x00MENTION_PROTECTED{idx}\x00", original)
|
||||
|
||||
return linked
|
||||
|
||||
def _protect_outbound_mention_regions(self, text: str) -> tuple[str, list[str]]:
|
||||
"""Protect markdown regions where outbound mentions should stay literal."""
|
||||
placeholders: list[str] = []
|
||||
|
||||
def _protect(fragment: str) -> str:
|
||||
idx = len(placeholders)
|
||||
placeholders.append(fragment)
|
||||
return f"\x00MENTION_PROTECTED{idx}\x00"
|
||||
|
||||
protected = re.sub(
|
||||
r"```[\s\S]*?```",
|
||||
lambda match: _protect(match.group(0)),
|
||||
text or "",
|
||||
)
|
||||
protected = re.sub(
|
||||
r"`[^`\n]+`",
|
||||
lambda match: _protect(match.group(0)),
|
||||
protected,
|
||||
)
|
||||
protected = re.sub(
|
||||
r"\[[^\]]+\]\([^)]+\)",
|
||||
lambda match: _protect(match.group(0)),
|
||||
protected,
|
||||
)
|
||||
|
||||
return protected, placeholders
|
||||
|
||||
def _is_bot_mentioned(
|
||||
self,
|
||||
body: str,
|
||||
@@ -2080,13 +2456,33 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
def _strip_mention(self, body: str) -> str:
|
||||
"""Strip the bot's full MXID (``@user:server``) from *body*.
|
||||
"""Remove explicit bot mentions from message body.
|
||||
|
||||
The bare localpart is intentionally *not* stripped — it would
|
||||
mangle file paths like ``/home/hermes/media/file.png``.
|
||||
Important: only strip explicit mention tokens (``@user:server`` or
|
||||
``@localpart``). Do NOT strip bare words matching the bot localpart,
|
||||
otherwise normal phrases like "Hermes Agent" become "Agent".
|
||||
"""
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
# Strip explicit full MXID mentions.
|
||||
if self._user_id:
|
||||
body = body.replace(self._user_id, "")
|
||||
|
||||
# Strip explicit @localpart mentions only (not bare localpart words).
|
||||
if self._user_id and ":" in self._user_id:
|
||||
localpart = self._user_id.split(":")[0].lstrip("@")
|
||||
if localpart:
|
||||
body = re.sub(
|
||||
r'(?<![\w])@' + re.escape(localpart) + r'\b',
|
||||
'',
|
||||
body,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Normalize spacing after mention removal.
|
||||
body = re.sub(r'[ \t]{2,}', ' ', body)
|
||||
body = re.sub(r'\s+([,.;:!?])', r'\1', body)
|
||||
return body.strip()
|
||||
|
||||
async def _get_display_name(self, room_id: str, user_id: str) -> str:
|
||||
|
||||
@@ -10041,7 +10041,7 @@ class GatewayRunner:
|
||||
# Bridge sync status_callback → async adapter.send for context pressure
|
||||
_status_adapter = self.adapters.get(source.platform)
|
||||
_status_chat_id = source.chat_id
|
||||
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
_status_thread_metadata = {"thread_id": _progress_thread_id, "mention_user_id": source.user_id} if _progress_thread_id else {"mention_user_id": source.user_id}
|
||||
|
||||
def _status_callback_sync(event_type: str, message: str) -> None:
|
||||
if not _status_adapter or not _run_still_current():
|
||||
|
||||
@@ -56,8 +56,18 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
|
||||
"MATRIX_RECOVERY_KEY",
|
||||
# Langfuse observability plugin — optional tuning keys + standard SDK vars
|
||||
"HERMES_LANGFUSE_ENABLED", # backward-compat env var (new: plugins.langfuse.enabled in config.yaml)
|
||||
"HERMES_LANGFUSE_ENV",
|
||||
"HERMES_LANGFUSE_RELEASE",
|
||||
"HERMES_LANGFUSE_SAMPLE_RATE",
|
||||
"HERMES_LANGFUSE_MAX_CHARS",
|
||||
"HERMES_LANGFUSE_DEBUG",
|
||||
"LANGFUSE_PUBLIC_KEY",
|
||||
"LANGFUSE_SECRET_KEY",
|
||||
"LANGFUSE_BASE_URL",
|
||||
})
|
||||
import yaml
|
||||
|
||||
@@ -942,7 +952,7 @@ DEFAULT_CONFIG = {
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": True,
|
||||
"redact_secrets": False,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
@@ -1692,6 +1702,30 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "tool",
|
||||
},
|
||||
|
||||
# ── Langfuse observability ──
|
||||
"HERMES_LANGFUSE_PUBLIC_KEY": {
|
||||
"description": "Langfuse project public key (pk-lf-...)",
|
||||
"prompt": "Langfuse public key",
|
||||
"url": "https://cloud.langfuse.com",
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"HERMES_LANGFUSE_SECRET_KEY": {
|
||||
"description": "Langfuse project secret key (sk-lf-...)",
|
||||
"prompt": "Langfuse secret key",
|
||||
"url": "https://cloud.langfuse.com",
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"HERMES_LANGFUSE_BASE_URL": {
|
||||
"description": "Langfuse server URL (default: https://cloud.langfuse.com)",
|
||||
"prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
"advanced": True,
|
||||
},
|
||||
|
||||
# ── Messaging platforms ──
|
||||
"TELEGRAM_BOT_TOKEN": {
|
||||
"description": "Telegram bot token from @BotFather",
|
||||
@@ -1839,6 +1873,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"MATRIX_DM_AUTO_THREAD": {
|
||||
"description": "Auto-create threads for DM messages in Matrix (default: false)",
|
||||
"prompt": "Auto-create threads in DMs (true/false)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"MATRIX_DEVICE_ID": {
|
||||
"description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
|
||||
"prompt": "Matrix device ID (stable across restarts)",
|
||||
@@ -3353,14 +3395,16 @@ def load_config() -> Dict[str, Any]:
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# Secret redaction is OFF by default — tool output (terminal stdout,
|
||||
# read_file results, web content) passes through unmodified. Set
|
||||
# redact_secrets to true to mask strings that look like API keys, tokens,
|
||||
# and passwords before they enter the model context and logs.
|
||||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||||
# is available. Configure via security.tirith_* keys or env vars
|
||||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: false
|
||||
# redact_secrets: true
|
||||
# tirith_enabled: true
|
||||
# tirith_path: "tirith"
|
||||
# tirith_timeout: 5
|
||||
@@ -3393,11 +3437,11 @@ _FALLBACK_COMMENT = """
|
||||
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# Secret redaction is OFF by default. Set to true to mask strings that
|
||||
# look like API keys, tokens, and passwords in tool output and logs.
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: false
|
||||
# redact_secrets: true
|
||||
|
||||
# ── Fallback Model ────────────────────────────────────────────────────
|
||||
# Automatic provider failover when primary is unavailable.
|
||||
|
||||
@@ -9082,7 +9082,11 @@ Examples:
|
||||
)
|
||||
plugins_remove.add_argument("name", help="Plugin directory name to remove")
|
||||
|
||||
plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
|
||||
plugins_list = plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
|
||||
plugins_list.add_argument(
|
||||
"--available", action="store_true",
|
||||
help="Also show official optional plugins that are not yet installed",
|
||||
)
|
||||
|
||||
plugins_enable = plugins_subparsers.add_parser(
|
||||
"enable", help="Enable a disabled plugin"
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
|
||||
|
||||
Plugins are installed from Git repositories into ``~/.hermes/plugins/``.
|
||||
Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub).
|
||||
Plugins can be installed from:
|
||||
- Official optional plugins shipped with the repo: ``official/<category>/<name>``
|
||||
- Git repositories (full URL or ``owner/repo`` GitHub shorthand)
|
||||
|
||||
Official plugins live in ``optional-plugins/`` inside the Hermes repo and are
|
||||
copied into ``~/.hermes/plugins/`` on install — no git clone needed, no network
|
||||
required. They are NOT auto-discovered from ``optional-plugins/``; only installed
|
||||
copies in ``~/.hermes/plugins/`` are loaded by Hermes.
|
||||
|
||||
After install, if the plugin ships an ``after-install.md`` file it is
|
||||
rendered with Rich Markdown. Otherwise a default confirmation is shown.
|
||||
@@ -95,10 +101,80 @@ def _resolve_git_url(identifier: str) -> str:
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid plugin identifier: '{identifier}'. "
|
||||
"Use a Git URL or owner/repo shorthand."
|
||||
"Use 'official/<category>/<name>', a Git URL, or owner/repo shorthand."
|
||||
)
|
||||
|
||||
|
||||
def _optional_plugins_dir() -> Path:
|
||||
"""Return the optional-plugins/ directory shipped with the Hermes repo."""
|
||||
return Path(__file__).resolve().parent.parent / "optional-plugins"
|
||||
|
||||
|
||||
def _resolve_official_plugin(identifier: str) -> Optional[Path]:
|
||||
"""If *identifier* is 'official/<category>/<name>', return its source path.
|
||||
|
||||
Returns ``None`` when the identifier is not in official format or the
|
||||
plugin directory does not exist.
|
||||
"""
|
||||
# Accept 'official/category/name' or just 'category/name' when the
|
||||
# category/name path exists under optional-plugins/.
|
||||
parts = identifier.strip("/").split("/")
|
||||
|
||||
# Strip leading 'official' prefix if present
|
||||
if parts and parts[0] == "official":
|
||||
parts = parts[1:]
|
||||
|
||||
if len(parts) < 1:
|
||||
return None
|
||||
|
||||
base = _optional_plugins_dir()
|
||||
# Try category/name (2 parts) or bare name (1 part)
|
||||
for nparts in (2, 1):
|
||||
if len(parts) < nparts:
|
||||
continue
|
||||
candidate = base.joinpath(*parts[-nparts:])
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
base_resolved = base.resolve()
|
||||
resolved.relative_to(base_resolved) # traversal guard
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if resolved.is_dir() and (
|
||||
(resolved / "plugin.yaml").exists() or (resolved / "__init__.py").exists()
|
||||
):
|
||||
return resolved
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _list_official_plugins() -> list[tuple[str, str]]:
|
||||
"""Return [(identifier, description), ...] for all official optional plugins."""
|
||||
base = _optional_plugins_dir()
|
||||
if not base.is_dir():
|
||||
return []
|
||||
|
||||
results = []
|
||||
for category_dir in sorted(base.iterdir()):
|
||||
if not category_dir.is_dir() or category_dir.name.startswith("."):
|
||||
continue
|
||||
for plugin_dir in sorted(category_dir.iterdir()):
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
|
||||
continue
|
||||
manifest_file = plugin_dir / "plugin.yaml"
|
||||
desc = ""
|
||||
if manifest_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(manifest_file.read_text()) or {}
|
||||
desc = data.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
identifier = f"official/{category_dir.name}/{plugin_dir.name}"
|
||||
results.append((identifier, desc))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _repo_name_from_url(url: str) -> str:
|
||||
"""Extract the repo name from a Git URL for the plugin directory name."""
|
||||
# Strip trailing .git and slashes
|
||||
@@ -296,7 +372,61 @@ def cmd_install(
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# ── Official optional plugins (no network, copied from optional-plugins/) ──
|
||||
official_src = _resolve_official_plugin(identifier)
|
||||
if official_src is not None:
|
||||
manifest = _read_manifest(official_src)
|
||||
plugin_name = manifest.get("name") or official_src.name
|
||||
target = _sanitize_plugin_name(plugin_name, plugins_dir)
|
||||
|
||||
if target.exists():
|
||||
if not force:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n"
|
||||
f"Use [bold]--force[/bold] to reinstall, or "
|
||||
f"[bold]hermes plugins update {plugin_name}[/bold] to update."
|
||||
)
|
||||
sys.exit(1)
|
||||
console.print(f"[dim] Removing existing {plugin_name}...[/dim]")
|
||||
shutil.rmtree(target)
|
||||
|
||||
console.print(f"[dim]Installing {plugin_name} from official optional plugins...[/dim]")
|
||||
shutil.copytree(str(official_src), str(target))
|
||||
|
||||
_copy_example_files(target, console)
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
_display_after_install(target, identifier)
|
||||
|
||||
installed_name = manifest.get("name") or target.name
|
||||
should_enable = enable
|
||||
if should_enable is None:
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
try:
|
||||
answer = input(" Enable now? [y/N] ").strip().lower()
|
||||
should_enable = answer in ("y", "yes")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
should_enable = False
|
||||
else:
|
||||
should_enable = False
|
||||
|
||||
if should_enable:
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
enabled.add(installed_name)
|
||||
disabled.discard(installed_name)
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(f" [green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.")
|
||||
else:
|
||||
console.print(
|
||||
f" [dim]Plugin installed but not enabled. "
|
||||
f"Run [bold]hermes plugins enable {installed_name}[/bold] to activate.[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
# ── Git URL / owner/repo install ──────────────────────────────────────────
|
||||
try:
|
||||
git_url = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
@@ -310,8 +440,6 @@ def cmd_install(
|
||||
"Consider using https:// or git@ for production installs."
|
||||
)
|
||||
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# Clone into a temp directory first so we can read plugin.yaml for the name
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_target = Path(tmp) / "plugin"
|
||||
@@ -696,16 +824,21 @@ def _discover_all_plugins() -> list:
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def cmd_list() -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
def cmd_list(available: bool = False) -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state.
|
||||
|
||||
When *available* is True, also show official optional plugins that are
|
||||
not yet installed.
|
||||
"""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
entries = _discover_all_plugins()
|
||||
if not entries:
|
||||
if not entries and not available:
|
||||
console.print("[dim]No plugins installed.[/dim]")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install official/<category>/<name>")
|
||||
console.print("[dim]Browse available:[/dim] hermes plugins list --available")
|
||||
return
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
@@ -734,6 +867,31 @@ def cmd_list() -> None:
|
||||
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
|
||||
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
|
||||
|
||||
if available:
|
||||
official = _list_official_plugins()
|
||||
if official:
|
||||
installed_names = {name for name, *_ in entries}
|
||||
def _is_installed(ident: str) -> bool:
|
||||
dirname = ident.rsplit("/", 1)[-1]
|
||||
# Check both the directory name (langfuse-tracing) and
|
||||
# common underscore variant (langfuse_tracing) since the
|
||||
# installed plugin uses the manifest name, not the dir name.
|
||||
return (dirname in installed_names
|
||||
or dirname.replace("-", "_") in installed_names)
|
||||
not_installed = [(ident, desc) for ident, desc in official
|
||||
if not _is_installed(ident)]
|
||||
if not_installed:
|
||||
console.print()
|
||||
avail_table = Table(title="Official optional plugins (not installed)", show_lines=False)
|
||||
avail_table.add_column("Identifier", style="bold")
|
||||
avail_table.add_column("Description")
|
||||
for ident, desc in not_installed:
|
||||
avail_table.add_row(ident, desc)
|
||||
console.print(avail_table)
|
||||
console.print("[dim]Install:[/dim] hermes plugins install official/<category>/<name>")
|
||||
else:
|
||||
console.print("[dim]All official optional plugins are already installed.[/dim]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider plugin discovery helpers
|
||||
@@ -1270,7 +1428,7 @@ def plugins_command(args) -> None:
|
||||
elif action == "disable":
|
||||
cmd_disable(args.name)
|
||||
elif action in ("list", "ls"):
|
||||
cmd_list()
|
||||
cmd_list(available=getattr(args, "available", False))
|
||||
elif action is None:
|
||||
cmd_toggle()
|
||||
else:
|
||||
|
||||
@@ -425,6 +425,31 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
"langfuse": {
|
||||
"name": "Langfuse Observability",
|
||||
"icon": "📊",
|
||||
"providers": [
|
||||
{
|
||||
"name": "Langfuse Cloud",
|
||||
"tag": "Hosted Langfuse (cloud.langfuse.com)",
|
||||
"env_vars": [
|
||||
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"},
|
||||
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"},
|
||||
],
|
||||
"post_setup": "langfuse",
|
||||
},
|
||||
{
|
||||
"name": "Langfuse Self-Hosted",
|
||||
"tag": "Self-hosted Langfuse instance",
|
||||
"env_vars": [
|
||||
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"},
|
||||
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"},
|
||||
{"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"},
|
||||
],
|
||||
"post_setup": "langfuse",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES.
|
||||
@@ -567,6 +592,31 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_info(" git submodule update --init --recursive")
|
||||
_print_info(' uv pip install -e "./tinker-atropos"')
|
||||
|
||||
elif post_setup_key == "langfuse":
|
||||
# Install the langfuse SDK.
|
||||
try:
|
||||
__import__("langfuse")
|
||||
_print_success(" langfuse SDK already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing langfuse SDK...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" langfuse SDK installed")
|
||||
else:
|
||||
_print_warning(" langfuse SDK install failed — run manually: pip install langfuse")
|
||||
# Install and enable the official optional plugin into ~/.hermes/plugins/.
|
||||
try:
|
||||
from hermes_cli.plugins_cmd import cmd_install as _plugins_install
|
||||
_plugins_install("official/observability/langfuse", enable=True)
|
||||
except SystemExit:
|
||||
pass # cmd_install prints its own errors and calls sys.exit
|
||||
_print_info(" Restart Hermes for tracing to take effect.")
|
||||
_print_info(" Verify: hermes plugins list")
|
||||
|
||||
|
||||
# ─── Platform / Toolset Helpers ───────────────────────────────────────────────
|
||||
|
||||
|
||||
234
hermes_state.py
234
hermes_state.py
@@ -285,130 +285,156 @@ class SessionDB:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_schema_columns(schema_sql: str) -> Dict[str, Dict[str, str]]:
|
||||
"""Extract expected columns per table from SCHEMA_SQL.
|
||||
|
||||
Uses an in-memory SQLite database to parse the SQL — SQLite itself
|
||||
handles all syntax (DEFAULT expressions with commas, inline
|
||||
REFERENCES, CHECK constraints, etc.) so there are zero regex
|
||||
edge cases. The in-memory DB is opened, the schema DDL is
|
||||
executed, and PRAGMA table_info extracts the column metadata.
|
||||
|
||||
Adding a column to SCHEMA_SQL is all that's needed; the
|
||||
reconciliation loop picks it up automatically.
|
||||
"""
|
||||
ref = sqlite3.connect(":memory:")
|
||||
try:
|
||||
ref.executescript(schema_sql)
|
||||
table_columns: Dict[str, Dict[str, str]] = {}
|
||||
for (tbl,) in ref.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall():
|
||||
cols: Dict[str, str] = {}
|
||||
for row in ref.execute(
|
||||
f'PRAGMA table_info("{tbl}")'
|
||||
).fetchall():
|
||||
# row: (cid, name, type, notnull, dflt_value, pk)
|
||||
col_name = row[1]
|
||||
col_type = row[2] or ""
|
||||
notnull = row[3]
|
||||
default = row[4]
|
||||
pk = row[5]
|
||||
# Reconstruct the type expression for ALTER TABLE ADD COLUMN
|
||||
parts = [col_type] if col_type else []
|
||||
if notnull and not pk:
|
||||
parts.append("NOT NULL")
|
||||
if default is not None:
|
||||
parts.append(f"DEFAULT {default}")
|
||||
cols[col_name] = " ".join(parts)
|
||||
table_columns[tbl] = cols
|
||||
return table_columns
|
||||
finally:
|
||||
ref.close()
|
||||
|
||||
def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""Ensure live tables have every column declared in SCHEMA_SQL.
|
||||
|
||||
Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
|
||||
in SCHEMA_SQL is the single source of truth for the desired schema.
|
||||
On every startup this method diffs the live columns (via PRAGMA
|
||||
table_info) against the declared columns, and ADDs any that are
|
||||
missing.
|
||||
|
||||
This makes column additions a declarative operation — just add
|
||||
the column to SCHEMA_SQL and it appears on the next startup.
|
||||
Version-gated migration blocks are no longer needed for ADD COLUMN.
|
||||
"""
|
||||
expected = self._parse_schema_columns(SCHEMA_SQL)
|
||||
for table_name, declared_cols in expected.items():
|
||||
# Get current columns from the live table
|
||||
try:
|
||||
rows = cursor.execute(
|
||||
f'PRAGMA table_info("{table_name}")'
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
continue # Table doesn't exist yet (shouldn't happen after executescript)
|
||||
live_cols = set()
|
||||
for row in rows:
|
||||
# PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)
|
||||
name = row[1] if isinstance(row, (tuple, list)) else row["name"]
|
||||
live_cols.add(name)
|
||||
|
||||
for col_name, col_type in declared_cols.items():
|
||||
if col_name not in live_cols:
|
||||
safe_name = col_name.replace('"', '""')
|
||||
try:
|
||||
cursor.execute(
|
||||
f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Expected: "duplicate column name" from a race or
|
||||
# re-run. Unexpected: "Cannot add a NOT NULL column
|
||||
# with default value NULL" from a schema mistake.
|
||||
# Log at DEBUG so it's visible in agent.log.
|
||||
logger.debug(
|
||||
"reconcile %s.%s: %s", table_name, col_name, exc,
|
||||
)
|
||||
|
||||
def _init_schema(self):
|
||||
"""Create tables and FTS if they don't exist, run migrations."""
|
||||
"""Create tables and FTS if they don't exist, reconcile columns.
|
||||
|
||||
Schema management follows the declarative reconciliation pattern
|
||||
(Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
|
||||
On existing databases, _reconcile_columns() diffs live columns
|
||||
against SCHEMA_SQL and ADDs any missing ones. This eliminates
|
||||
the version-gated migration chain for column additions, making
|
||||
it impossible for reordered or inserted migrations to skip columns.
|
||||
|
||||
The schema_version table is retained for future data migrations
|
||||
(transforming existing rows) which cannot be handled declaratively.
|
||||
"""
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
cursor.executescript(SCHEMA_SQL)
|
||||
|
||||
# Check schema version and run migrations
|
||||
# ── Declarative column reconciliation ──────────────────────────
|
||||
# Diff live tables against SCHEMA_SQL and ADD any missing columns.
|
||||
# This is idempotent and self-healing: even if a version-gated
|
||||
# migration was skipped (e.g. due to version renumbering), the
|
||||
# column gets created here.
|
||||
self._reconcile_columns(cursor)
|
||||
|
||||
# ── Schema version bookkeeping ─────────────────────────────────
|
||||
# Bump to current so future data migrations (if any) can gate on
|
||||
# version. No version-gated column additions remain.
|
||||
cursor.execute("SELECT version FROM schema_version LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
cursor.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
|
||||
cursor.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?)",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
else:
|
||||
current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
if current_version < 2:
|
||||
# v2: add finish_reason column to messages
|
||||
try:
|
||||
cursor.execute("ALTER TABLE messages ADD COLUMN finish_reason TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 2")
|
||||
if current_version < 3:
|
||||
# v3: add title column to sessions
|
||||
try:
|
||||
cursor.execute("ALTER TABLE sessions ADD COLUMN title TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 3")
|
||||
if current_version < 4:
|
||||
# v4: add unique index on title (NULLs allowed, only non-NULL must be unique)
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||
"ON sessions(title) WHERE title IS NOT NULL"
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Index already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 4")
|
||||
if current_version < 5:
|
||||
new_columns = [
|
||||
("cache_read_tokens", "INTEGER DEFAULT 0"),
|
||||
("cache_write_tokens", "INTEGER DEFAULT 0"),
|
||||
("reasoning_tokens", "INTEGER DEFAULT 0"),
|
||||
("billing_provider", "TEXT"),
|
||||
("billing_base_url", "TEXT"),
|
||||
("billing_mode", "TEXT"),
|
||||
("estimated_cost_usd", "REAL"),
|
||||
("actual_cost_usd", "REAL"),
|
||||
("cost_status", "TEXT"),
|
||||
("cost_source", "TEXT"),
|
||||
("pricing_version", "TEXT"),
|
||||
]
|
||||
for name, column_type in new_columns:
|
||||
try:
|
||||
# name and column_type come from the hardcoded tuple above,
|
||||
# not user input. Double-quote identifier escaping is applied
|
||||
# as defense-in-depth; SQLite DDL cannot be parameterized.
|
||||
safe_name = name.replace('"', '""')
|
||||
cursor.execute(f'ALTER TABLE sessions ADD COLUMN "{safe_name}" {column_type}')
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
cursor.execute("UPDATE schema_version SET version = 5")
|
||||
if current_version < 6:
|
||||
# v6: add reasoning columns to messages table — preserves assistant
|
||||
# reasoning text and structured reasoning_details across gateway
|
||||
# session turns. Without these, reasoning chains are lost on
|
||||
# session reload, breaking multi-turn reasoning continuity for
|
||||
# providers that replay reasoning (OpenRouter, OpenAI, Nous).
|
||||
for col_name, col_type in [
|
||||
("reasoning", "TEXT"),
|
||||
("reasoning_details", "TEXT"),
|
||||
("codex_reasoning_items", "TEXT"),
|
||||
]:
|
||||
try:
|
||||
safe = col_name.replace('"', '""')
|
||||
cursor.execute(
|
||||
f'ALTER TABLE messages ADD COLUMN "{safe}" {col_type}'
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 6")
|
||||
if current_version < 7:
|
||||
# v7: preserve provider-native reasoning_content separately from
|
||||
# normalized reasoning text. Kimi/Moonshot replay can require
|
||||
# this field on assistant tool-call messages when thinking is on.
|
||||
try:
|
||||
cursor.execute('ALTER TABLE messages ADD COLUMN "reasoning_content" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 7")
|
||||
if current_version < 8:
|
||||
# v8: add api_call_count column to sessions — tracks the number
|
||||
# of individual LLM API calls made within a session (as opposed
|
||||
# to the session count itself).
|
||||
try:
|
||||
cursor.execute(
|
||||
'ALTER TABLE sessions ADD COLUMN "api_call_count" INTEGER DEFAULT 0'
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 8")
|
||||
if current_version < 9:
|
||||
# v9: preserve replayable Codex assistant message ids/phases so
|
||||
# follow-up turns can rebuild Responses API message items instead
|
||||
# of flattening everything to plain assistant text.
|
||||
try:
|
||||
cursor.execute('ALTER TABLE messages ADD COLUMN "codex_message_items" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 9")
|
||||
# Data migrations that can't be expressed declaratively (row
|
||||
# backfills, index changes tied to a specific version step) stay
|
||||
# in a version-gated chain. Column additions are handled by
|
||||
# _reconcile_columns() above and no longer need entries here.
|
||||
if current_version < 10:
|
||||
# v10: trigram FTS5 table for CJK/substring search.
|
||||
# Created via FTS_TRIGRAM_SQL below; backfill existing messages.
|
||||
# v10: trigram FTS5 table for CJK/substring search. The
|
||||
# virtual table + triggers are created unconditionally via
|
||||
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
|
||||
# backfill into the FTS index.
|
||||
try:
|
||||
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
|
||||
_fts_trigram_exists = True
|
||||
except sqlite3.OperationalError:
|
||||
_fts_trigram_exists = False
|
||||
if not _fts_trigram_exists:
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, content FROM messages WHERE content IS NOT NULL"
|
||||
)
|
||||
cursor.execute("UPDATE schema_version SET version = 10")
|
||||
if current_version < SCHEMA_VERSION:
|
||||
cursor.execute(
|
||||
"UPDATE schema_version SET version = ?",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
|
||||
# Unique title index — always ensure it exists (safe to run after migrations
|
||||
# since the title column is guaranteed to exist at this point)
|
||||
# Unique title index — always ensure it exists
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||
|
||||
875
optional-plugins/observability/langfuse/__init__.py
Normal file
875
optional-plugins/observability/langfuse/__init__.py
Normal file
@@ -0,0 +1,875 @@
|
||||
"""langfuse — Hermes plugin for Langfuse observability.
|
||||
|
||||
Traces Hermes conversations, LLM calls, and tool usage to Langfuse.
|
||||
Enable via ``hermes tools`` or by setting HERMES_LANGFUSE_ENABLED=true
|
||||
and the required credentials in ~/.hermes/.env.
|
||||
|
||||
Required env vars (set via ``hermes tools`` or ~/.hermes/.env):
|
||||
HERMES_LANGFUSE_ENABLED - set to "true" to activate tracing
|
||||
HERMES_LANGFUSE_PUBLIC_KEY - Langfuse project public key (pk-lf-...)
|
||||
HERMES_LANGFUSE_SECRET_KEY - Langfuse project secret key (sk-lf-...)
|
||||
HERMES_LANGFUSE_BASE_URL - Langfuse server URL (default: https://cloud.langfuse.com)
|
||||
|
||||
Optional env vars:
|
||||
HERMES_LANGFUSE_ENV - environment tag (e.g. "production", "local")
|
||||
HERMES_LANGFUSE_RELEASE - release/version tag
|
||||
HERMES_LANGFUSE_SAMPLE_RATE - sampling rate 0.0–1.0 (default: 1.0)
|
||||
HERMES_LANGFUSE_MAX_CHARS - max chars per field (default: 12000)
|
||||
HERMES_LANGFUSE_DEBUG - set to "true" for verbose logging
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from langfuse import Langfuse, propagate_attributes
|
||||
except Exception: # pragma: no cover - fail-open when optional dep is missing
|
||||
Langfuse = None
|
||||
propagate_attributes = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraceState:
|
||||
trace_id: str
|
||||
root_ctx: Any
|
||||
root_span: Any
|
||||
generations: Dict[str, Any] = field(default_factory=dict)
|
||||
tools: Dict[str, Any] = field(default_factory=dict)
|
||||
turn_tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
last_updated_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
_STATE_LOCK = threading.Lock()
|
||||
_TRACE_STATE: Dict[str, TraceState] = {}
|
||||
_LANGFUSE_CLIENT = None
|
||||
_READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$")
|
||||
_READ_FILE_HEAD_LINES = 25
|
||||
_READ_FILE_TAIL_LINES = 15
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default).strip()
|
||||
|
||||
|
||||
def _env_bool(*names: str) -> bool:
|
||||
for name in names:
|
||||
value = _env(name).lower()
|
||||
if value:
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
return False
|
||||
|
||||
|
||||
def _debug_enabled() -> bool:
|
||||
return _env_bool("HERMES_LANGFUSE_DEBUG")
|
||||
|
||||
|
||||
def _debug(message: str) -> None:
|
||||
if _debug_enabled():
|
||||
logger.info("Langfuse tracing: %s", message)
|
||||
|
||||
|
||||
def _is_enabled() -> bool:
|
||||
if Langfuse is None:
|
||||
return False
|
||||
# Primary activation path: config.yaml plugins.langfuse.enabled
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
_plugin_cfg = _cfg.get("plugins", {})
|
||||
if isinstance(_plugin_cfg, dict):
|
||||
_lt_cfg = _plugin_cfg.get("langfuse", {})
|
||||
if isinstance(_lt_cfg, dict) and "enabled" in _lt_cfg:
|
||||
if not _lt_cfg["enabled"]:
|
||||
return False
|
||||
# Explicit enabled=true in config — skip env-var check below
|
||||
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
|
||||
return bool(public_key and secret_key)
|
||||
except Exception:
|
||||
pass
|
||||
# Backward-compat path: HERMES_LANGFUSE_ENABLED env var (legacy .env installs)
|
||||
if not _env_bool("HERMES_LANGFUSE_ENABLED"):
|
||||
return False
|
||||
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
|
||||
return bool(public_key and secret_key)
|
||||
|
||||
|
||||
def _get_langfuse() -> Optional[Langfuse]:
|
||||
global _LANGFUSE_CLIENT
|
||||
if not _is_enabled():
|
||||
return None
|
||||
if _LANGFUSE_CLIENT is not None:
|
||||
return _LANGFUSE_CLIENT
|
||||
|
||||
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
|
||||
base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com"
|
||||
environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV")
|
||||
release = _env("HERMES_LANGFUSE_RELEASE") or _env("LANGFUSE_RELEASE")
|
||||
sample_rate = _env("HERMES_LANGFUSE_SAMPLE_RATE")
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"public_key": public_key,
|
||||
"secret_key": secret_key,
|
||||
"base_url": base_url,
|
||||
}
|
||||
if environment:
|
||||
kwargs["environment"] = environment
|
||||
if release:
|
||||
kwargs["release"] = release
|
||||
if sample_rate:
|
||||
try:
|
||||
kwargs["sample_rate"] = float(sample_rate)
|
||||
except ValueError:
|
||||
logger.warning("Invalid HERMES_LANGFUSE_SAMPLE_RATE=%r", sample_rate)
|
||||
|
||||
try:
|
||||
_LANGFUSE_CLIENT = Langfuse(**kwargs)
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
logger.warning("Could not initialize Langfuse client: %s", exc)
|
||||
return None
|
||||
|
||||
return _LANGFUSE_CLIENT
|
||||
|
||||
|
||||
def _trace_key(task_id: str, session_id: str) -> str:
|
||||
if task_id:
|
||||
return task_id
|
||||
if session_id:
|
||||
return f"session:{session_id}"
|
||||
return f"thread:{threading.get_ident()}"
|
||||
|
||||
|
||||
def _truncate_text(value: str, max_chars: int) -> str:
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
|
||||
|
||||
|
||||
def _maybe_parse_json_string(value: str) -> Any:
|
||||
stripped = value.strip()
|
||||
if len(stripped) < 2 or stripped[0] not in "{[" or stripped[-1] not in "}]":
|
||||
if len(stripped) < 2 or stripped[0] not in "{[":
|
||||
return value
|
||||
try:
|
||||
parsed, idx = json.JSONDecoder().raw_decode(stripped)
|
||||
except Exception:
|
||||
return value
|
||||
if not isinstance(parsed, (dict, list)):
|
||||
return value
|
||||
|
||||
trailing = stripped[idx:].strip()
|
||||
if not trailing:
|
||||
return parsed
|
||||
|
||||
hint_key = "_hint" if trailing.startswith("[Hint:") else "_trailing_text"
|
||||
if isinstance(parsed, dict):
|
||||
merged = dict(parsed)
|
||||
key = hint_key if hint_key not in merged else "_trailing_text"
|
||||
merged[key] = trailing
|
||||
return merged
|
||||
|
||||
return {"data": parsed, hint_key: trailing}
|
||||
|
||||
|
||||
def _looks_like_read_file_payload(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
content = value.get("content")
|
||||
return (
|
||||
isinstance(content, str)
|
||||
and "total_lines" in value
|
||||
and "file_size" in value
|
||||
and "is_binary" in value
|
||||
and "is_image" in value
|
||||
and not value.get("error")
|
||||
)
|
||||
|
||||
|
||||
def _parse_read_file_lines(content: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(content, str) or not content:
|
||||
return []
|
||||
|
||||
lines = []
|
||||
for raw_line in content.splitlines():
|
||||
match = _READ_FILE_LINE_RE.match(raw_line)
|
||||
if not match:
|
||||
return []
|
||||
lines.append({
|
||||
"line": int(match.group(1)),
|
||||
"text": match.group(2),
|
||||
})
|
||||
return lines
|
||||
|
||||
|
||||
def _build_read_file_preview(lines: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if len(lines) <= (_READ_FILE_HEAD_LINES + _READ_FILE_TAIL_LINES):
|
||||
return {"lines": lines}
|
||||
|
||||
return {
|
||||
"head": lines[:_READ_FILE_HEAD_LINES],
|
||||
"tail": lines[-_READ_FILE_TAIL_LINES:],
|
||||
"omitted_line_count": len(lines) - _READ_FILE_HEAD_LINES - _READ_FILE_TAIL_LINES,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_read_file_payload(value: dict[str, Any], *, args: Any = None) -> dict[str, Any]:
|
||||
normalized: dict[str, Any] = {}
|
||||
if isinstance(args, dict):
|
||||
path = args.get("path")
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if isinstance(path, str) and path:
|
||||
normalized["path"] = path
|
||||
if isinstance(offset, int):
|
||||
normalized["offset"] = offset
|
||||
if isinstance(limit, int):
|
||||
normalized["limit"] = limit
|
||||
|
||||
lines = _parse_read_file_lines(value.get("content", ""))
|
||||
if lines:
|
||||
normalized["returned_lines"] = {
|
||||
"start": lines[0]["line"],
|
||||
"end": lines[-1]["line"],
|
||||
"count": len(lines),
|
||||
}
|
||||
normalized["content_preview"] = _build_read_file_preview(lines)
|
||||
elif value.get("content"):
|
||||
normalized["content_preview"] = {
|
||||
"text": value.get("content", ""),
|
||||
}
|
||||
|
||||
for key in (
|
||||
"total_lines",
|
||||
"file_size",
|
||||
"truncated",
|
||||
"is_binary",
|
||||
"is_image",
|
||||
"hint",
|
||||
"_warning",
|
||||
"mime_type",
|
||||
"dimensions",
|
||||
"similar_files",
|
||||
"error",
|
||||
):
|
||||
if key in value:
|
||||
normalized[key] = value[key]
|
||||
|
||||
base64_content = value.get("base64_content")
|
||||
if isinstance(base64_content, str) and base64_content:
|
||||
normalized["base64_content"] = {
|
||||
"omitted": True,
|
||||
"length": len(base64_content),
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_payload(value: Any, *, tool_name: str = "", args: Any = None) -> Any:
|
||||
if _looks_like_read_file_payload(value):
|
||||
return _normalize_read_file_payload(
|
||||
value,
|
||||
args=args if tool_name == "read_file" else None,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _safe_value(value: Any, *, max_chars: Optional[int] = None, depth: int = 0,
|
||||
parse_json_strings: bool = False) -> Any:
|
||||
max_chars = max_chars if max_chars is not None else int(_env("HERMES_LANGFUSE_MAX_CHARS", "12000") or "12000")
|
||||
if depth > 4:
|
||||
return "<max-depth>"
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return {"type": "bytes", "len": len(value)}
|
||||
if isinstance(value, str):
|
||||
if parse_json_strings:
|
||||
parsed = _maybe_parse_json_string(value)
|
||||
if parsed is not value:
|
||||
return _safe_value(parsed, max_chars=max_chars, depth=depth, parse_json_strings=True)
|
||||
return _truncate_text(value, max_chars)
|
||||
if isinstance(value, dict):
|
||||
normalized = _normalize_payload(value)
|
||||
if normalized is not value:
|
||||
return _safe_value(normalized, max_chars=max_chars, depth=depth, parse_json_strings=parse_json_strings)
|
||||
return {
|
||||
str(k): _safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
for k, v in list(value.items())[:50]
|
||||
}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [
|
||||
_safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
for v in list(value)[:50]
|
||||
]
|
||||
if hasattr(value, "__dict__"):
|
||||
return _safe_value(vars(value), max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
return _truncate_text(repr(value), max_chars)
|
||||
|
||||
|
||||
def _extract_last_user_message(messages: Any) -> Any:
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get("role") == "user":
|
||||
return {
|
||||
"role": "user",
|
||||
"content": _safe_value(message.get("content")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_messages(messages: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(messages, list):
|
||||
return []
|
||||
serialized = []
|
||||
for message in messages[-12:]:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
role = message.get("role")
|
||||
item = {
|
||||
"role": role,
|
||||
"content": _safe_value(
|
||||
message.get("content"),
|
||||
parse_json_strings=(role == "tool"),
|
||||
),
|
||||
}
|
||||
if role == "tool" and message.get("tool_call_id"):
|
||||
item["tool_call_id"] = message.get("tool_call_id")
|
||||
if message.get("tool_calls"):
|
||||
item["tool_calls"] = _safe_value(message.get("tool_calls"), parse_json_strings=True)
|
||||
serialized.append(item)
|
||||
return serialized
|
||||
|
||||
|
||||
def _serialize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]:
|
||||
if not tool_calls:
|
||||
return []
|
||||
serialized = []
|
||||
for tool_call in tool_calls:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", None) if fn else None
|
||||
arguments = getattr(fn, "arguments", None) if fn else None
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except Exception:
|
||||
pass
|
||||
serialized.append({
|
||||
"id": getattr(tool_call, "id", None),
|
||||
"name": name,
|
||||
"arguments": _safe_value(arguments, parse_json_strings=True),
|
||||
})
|
||||
return serialized
|
||||
|
||||
|
||||
def _serialize_assistant_message(message: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"content": _safe_value(getattr(message, "content", None)),
|
||||
"reasoning": _safe_value(getattr(message, "reasoning", None)),
|
||||
"tool_calls": _serialize_tool_calls(getattr(message, "tool_calls", None)),
|
||||
}
|
||||
|
||||
|
||||
def _usage_and_cost(response: Any, *, provider: str, api_mode: str, model: str, base_url: str) -> tuple[dict[str, int], dict[str, float]]:
|
||||
usage_details: Dict[str, int] = {}
|
||||
cost_details: Dict[str, float] = {}
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if not raw_usage:
|
||||
return usage_details, cost_details
|
||||
|
||||
try:
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
|
||||
canonical = normalize_usage(raw_usage, provider=provider, api_mode=api_mode)
|
||||
# Langfuse usage_details keys follow a naming convention:
|
||||
# - Dashboard sums all keys containing "input" as input total
|
||||
# - Dashboard sums all keys containing "output" as output total
|
||||
# - If no "total" key, Langfuse derives it from all usage types
|
||||
# Use Anthropic-style key names so cache tokens roll into the
|
||||
# dashboard input total automatically.
|
||||
# Ref: https://langfuse.com/docs/model-usage-and-cost
|
||||
usage_details = {
|
||||
"input": canonical.input_tokens,
|
||||
"output": canonical.output_tokens,
|
||||
}
|
||||
if canonical.cache_read_tokens:
|
||||
usage_details["cache_read_input_tokens"] = canonical.cache_read_tokens
|
||||
if canonical.cache_write_tokens:
|
||||
usage_details["cache_creation_input_tokens"] = canonical.cache_write_tokens
|
||||
if canonical.reasoning_tokens:
|
||||
usage_details["reasoning_tokens"] = canonical.reasoning_tokens
|
||||
cost = estimate_usage_cost(
|
||||
model,
|
||||
canonical,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key="",
|
||||
)
|
||||
if cost.amount_usd is not None:
|
||||
# Langfuse cost_details keys must match usage_details keys.
|
||||
# Provide per-type breakdown so dashboard can show cost by type.
|
||||
try:
|
||||
from agent.usage_pricing import get_pricing_entry
|
||||
from decimal import Decimal
|
||||
_ONE_M = Decimal("1000000")
|
||||
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
|
||||
if entry:
|
||||
if entry.input_cost_per_million is not None and canonical.input_tokens:
|
||||
cost_details["input"] = float(Decimal(canonical.input_tokens) * entry.input_cost_per_million / _ONE_M)
|
||||
if entry.output_cost_per_million is not None and canonical.output_tokens:
|
||||
cost_details["output"] = float(Decimal(canonical.output_tokens) * entry.output_cost_per_million / _ONE_M)
|
||||
if entry.cache_read_cost_per_million is not None and canonical.cache_read_tokens:
|
||||
cost_details["cache_read_input_tokens"] = float(Decimal(canonical.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_M)
|
||||
if entry.cache_write_cost_per_million is not None and canonical.cache_write_tokens:
|
||||
cost_details["cache_creation_input_tokens"] = float(Decimal(canonical.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_M)
|
||||
else:
|
||||
cost_details["total"] = float(cost.amount_usd)
|
||||
except Exception:
|
||||
cost_details["total"] = float(cost.amount_usd)
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"usage normalization failed: {exc}")
|
||||
|
||||
return usage_details, cost_details
|
||||
|
||||
|
||||
def _start_root_trace(task_key: str, *, task_id: str, session_id: str, platform: str, provider: str, model: str,
|
||||
api_mode: str, messages: Any, client: Langfuse) -> TraceState:
|
||||
trace_id = client.create_trace_id(seed=f"{session_id or 'sessionless'}::{task_id or task_key}")
|
||||
trace_input = _extract_last_user_message(messages)
|
||||
metadata = {
|
||||
"source": "hermes",
|
||||
"task_id": task_id,
|
||||
"platform": platform,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_mode": api_mode,
|
||||
}
|
||||
|
||||
# session_id must be passed in trace_context for Langfuse session grouping.
|
||||
trace_ctx: Dict[str, Any] = {"trace_id": trace_id}
|
||||
if session_id:
|
||||
trace_ctx["session_id"] = session_id
|
||||
|
||||
if propagate_attributes is not None:
|
||||
try:
|
||||
with propagate_attributes(
|
||||
session_id=session_id or task_key,
|
||||
trace_name="Hermes turn",
|
||||
tags=["hermes", "langfuse"],
|
||||
):
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
except Exception:
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
else:
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
|
||||
try:
|
||||
root_span.set_trace_io(input=trace_input)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_debug(f"started trace {trace_id} for {task_key}")
|
||||
return TraceState(trace_id=trace_id, root_ctx=root_ctx, root_span=root_span)
|
||||
|
||||
|
||||
def _start_child_observation(state: TraceState, *, client: Langfuse, name: str, as_type: str,
|
||||
input_value: Any, metadata: Optional[dict] = None,
|
||||
model: Optional[str] = None, model_parameters: Optional[dict] = None) -> Any:
|
||||
return state.root_span.start_observation(
|
||||
name=name,
|
||||
as_type=as_type,
|
||||
input=input_value,
|
||||
metadata=metadata or {},
|
||||
model=model,
|
||||
model_parameters=model_parameters,
|
||||
)
|
||||
|
||||
|
||||
def _end_observation(observation: Any, *, output: Any = None, metadata: Optional[dict] = None,
|
||||
usage_details: Optional[dict] = None, cost_details: Optional[dict] = None) -> None:
|
||||
if observation is None:
|
||||
return
|
||||
try:
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
if output is not None:
|
||||
update_kwargs["output"] = output
|
||||
if metadata:
|
||||
update_kwargs["metadata"] = metadata
|
||||
if usage_details:
|
||||
update_kwargs["usage_details"] = usage_details
|
||||
if cost_details:
|
||||
update_kwargs["cost_details"] = cost_details
|
||||
if update_kwargs:
|
||||
observation.update(**update_kwargs)
|
||||
observation.end()
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"end observation failed: {exc}")
|
||||
|
||||
|
||||
def _merge_trace_output(output: Any, state: TraceState) -> Any:
|
||||
if not state.turn_tool_calls:
|
||||
return output
|
||||
|
||||
merged = dict(output) if isinstance(output, dict) else {"content": output}
|
||||
merged["tool_calls"] = list(state.turn_tool_calls)
|
||||
return merged
|
||||
|
||||
|
||||
def _finish_trace(task_key: str, *, output: Any = None) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.pop(task_key, None)
|
||||
if state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
for observation in state.generations.values():
|
||||
_end_observation(observation)
|
||||
for observation in state.tools.values():
|
||||
_end_observation(observation)
|
||||
final_output = _merge_trace_output(output, state)
|
||||
if final_output is not None:
|
||||
state.root_span.set_trace_io(output=final_output)
|
||||
state.root_span.update(output=final_output)
|
||||
state.root_span.end()
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"finish trace failed: {exc}")
|
||||
finally:
|
||||
try:
|
||||
client.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _assistant_has_tool_calls(message: Any) -> bool:
|
||||
return bool(getattr(message, "tool_calls", None))
|
||||
|
||||
|
||||
def _request_key(api_call_count: Any) -> str:
|
||||
return str(api_call_count or 0)
|
||||
|
||||
|
||||
def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str = "", model: str = "",
|
||||
provider: str = "", base_url: str = "", api_mode: str = "",
|
||||
api_call_count: int = 0, messages: Any = None, turn_type: str = "user",
|
||||
conversation_history: Any = None, user_message: Any = None, **_: Any) -> None:
|
||||
# Older Hermes branches used pre_llm_call for request-scoped tracing and
|
||||
# passed the actual API messages. Current Hermes also has a turn-scoped
|
||||
# pre_llm_call used for context injection; tracing that hook creates an
|
||||
# extra orphan/root trace before the real request trace. Only trace the
|
||||
# legacy request-shaped call here.
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
# messages is a list only for legacy Hermes branches that fired
|
||||
# pre_llm_call with API messages directly. Current Hermes fires
|
||||
# pre_llm_call for context injection (conversation_history/user_message,
|
||||
# no messages list) — tracing that would create orphan traces.
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
state = _start_root_trace(
|
||||
task_key,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
platform=platform,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_mode=api_mode,
|
||||
messages=messages,
|
||||
client=client,
|
||||
)
|
||||
_TRACE_STATE[task_key] = state
|
||||
state.last_updated_at = time.time()
|
||||
|
||||
|
||||
def on_pre_llm_request(
|
||||
*,
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
platform: str = "",
|
||||
model: str = "",
|
||||
provider: str = "",
|
||||
base_url: str = "",
|
||||
api_mode: str = "",
|
||||
api_call_count: int = 0,
|
||||
messages: Any = None,
|
||||
turn_type: str = "user",
|
||||
message_count: int = 0,
|
||||
tool_count: int = 0,
|
||||
approx_input_tokens: int = 0,
|
||||
request_char_count: int = 0,
|
||||
max_tokens: Any = None,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
req_key = _request_key(api_call_count)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
state = _start_root_trace(
|
||||
task_key,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
platform=platform,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_mode=api_mode,
|
||||
messages=messages,
|
||||
client=client,
|
||||
)
|
||||
_TRACE_STATE[task_key] = state
|
||||
state.last_updated_at = time.time()
|
||||
previous = state.generations.pop(req_key, None)
|
||||
if previous is not None:
|
||||
_end_observation(previous)
|
||||
state.generations[req_key] = _start_child_observation(
|
||||
state,
|
||||
client=client,
|
||||
name=f"LLM call {api_call_count}",
|
||||
as_type="generation",
|
||||
input_value=_serialize_messages(messages),
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"platform": platform,
|
||||
"api_mode": api_mode,
|
||||
"base_url": base_url,
|
||||
},
|
||||
model=model,
|
||||
model_parameters={"api_mode": api_mode, "provider": provider},
|
||||
)
|
||||
|
||||
|
||||
def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str = "", base_url: str = "",
|
||||
api_mode: str = "", model: str = "", api_call_count: int = 0,
|
||||
assistant_message: Any = None, response: Any = None,
|
||||
api_duration: float = 0.0, finish_reason: str = "",
|
||||
usage: Any = None, assistant_content_chars: int = 0,
|
||||
assistant_tool_call_count: int = 0, assistant_response: Any = None,
|
||||
**_: Any) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
req_key = _request_key(api_call_count)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
generation = state.generations.pop(req_key, None) if state else None
|
||||
if state is None or generation is None:
|
||||
return
|
||||
|
||||
# Handle both call patterns:
|
||||
# 1. post_api_request: passes usage (dict), assistant_content_chars, assistant_tool_call_count
|
||||
# 2. post_llm_call: passes assistant_message (object), response (object), assistant_response (str)
|
||||
if assistant_message is not None:
|
||||
output = _serialize_assistant_message(assistant_message)
|
||||
elif assistant_response is not None:
|
||||
# post_llm_call passes assistant_response as a plain string
|
||||
output = {"content": _safe_value(assistant_response), "reasoning": None, "tool_calls": []}
|
||||
else:
|
||||
# post_api_request path — reconstruct from summary kwargs
|
||||
output = {
|
||||
"content": f"[{assistant_content_chars} chars]" if assistant_content_chars else None,
|
||||
"reasoning": None,
|
||||
"tool_calls": [{"id": f"tc_{i}"} for i in range(assistant_tool_call_count)] if assistant_tool_call_count else [],
|
||||
}
|
||||
|
||||
if output.get("tool_calls"):
|
||||
state.turn_tool_calls.extend(output["tool_calls"])
|
||||
|
||||
# Extract usage: prefer response object, fall back to usage dict from post_api_request
|
||||
if response is not None:
|
||||
usage_details, cost_details = _usage_and_cost(
|
||||
response,
|
||||
provider=provider,
|
||||
api_mode=api_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
)
|
||||
elif isinstance(usage, dict) and usage:
|
||||
# post_api_request passes a pre-built CanonicalUsage summary dict.
|
||||
# Use Langfuse-convention key names: "input", "output", and
|
||||
# "cache_read_input_tokens" / "cache_creation_input_tokens" so the
|
||||
# dashboard sums cache tokens into the input total automatically.
|
||||
_input = usage.get("input_tokens", 0)
|
||||
_output = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
|
||||
_cache_read = usage.get("cache_read_tokens", 0)
|
||||
_cache_write = usage.get("cache_write_tokens", 0)
|
||||
_reasoning = usage.get("reasoning_tokens", 0)
|
||||
usage_details = {
|
||||
"input": _input,
|
||||
"output": _output,
|
||||
}
|
||||
if _cache_read:
|
||||
usage_details["cache_read_input_tokens"] = _cache_read
|
||||
if _cache_write:
|
||||
usage_details["cache_creation_input_tokens"] = _cache_write
|
||||
if _reasoning:
|
||||
usage_details["reasoning_tokens"] = _reasoning
|
||||
cost_details = {}
|
||||
# Estimate per-type cost from the summary if possible
|
||||
try:
|
||||
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost, get_pricing_entry
|
||||
from decimal import Decimal
|
||||
_ONE_M = Decimal("1000000")
|
||||
_cu = CanonicalUsage(
|
||||
input_tokens=_input,
|
||||
output_tokens=_output,
|
||||
cache_read_tokens=_cache_read,
|
||||
cache_write_tokens=_cache_write,
|
||||
reasoning_tokens=_reasoning,
|
||||
)
|
||||
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
|
||||
if entry:
|
||||
if entry.input_cost_per_million is not None and _input:
|
||||
cost_details["input"] = float(Decimal(_input) * entry.input_cost_per_million / _ONE_M)
|
||||
if entry.output_cost_per_million is not None and _output:
|
||||
cost_details["output"] = float(Decimal(_output) * entry.output_cost_per_million / _ONE_M)
|
||||
if entry.cache_read_cost_per_million is not None and _cache_read:
|
||||
cost_details["cache_read_input_tokens"] = float(Decimal(_cache_read) * entry.cache_read_cost_per_million / _ONE_M)
|
||||
if entry.cache_write_cost_per_million is not None and _cache_write:
|
||||
cost_details["cache_creation_input_tokens"] = float(Decimal(_cache_write) * entry.cache_write_cost_per_million / _ONE_M)
|
||||
else:
|
||||
_cost = estimate_usage_cost(model, _cu, provider=provider, base_url=base_url, api_key="")
|
||||
if _cost.amount_usd is not None:
|
||||
cost_details["total"] = float(_cost.amount_usd)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
usage_details, cost_details = {}, {}
|
||||
|
||||
tool_count = len(output.get("tool_calls", [])) or assistant_tool_call_count
|
||||
gen_metadata: Dict[str, Any] = {"tool_call_count": tool_count}
|
||||
if api_duration and api_duration > 0:
|
||||
gen_metadata["api_duration_s"] = round(api_duration, 3)
|
||||
if finish_reason:
|
||||
gen_metadata["finish_reason"] = finish_reason
|
||||
_end_observation(
|
||||
generation,
|
||||
output=output,
|
||||
usage_details=usage_details,
|
||||
cost_details=cost_details,
|
||||
metadata=gen_metadata,
|
||||
)
|
||||
|
||||
has_tools = _assistant_has_tool_calls(assistant_message) if assistant_message else (assistant_tool_call_count > 0)
|
||||
has_content = bool(output.get("content"))
|
||||
if not has_tools and has_content:
|
||||
_finish_trace(task_key, output=output)
|
||||
|
||||
|
||||
def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "",
|
||||
session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
tool_key = tool_call_id or f"{tool_name}:{time.time_ns()}"
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
return
|
||||
state.tools[tool_key] = _start_child_observation(
|
||||
state,
|
||||
client=client,
|
||||
name=f"Tool: {tool_name}",
|
||||
as_type="tool",
|
||||
input_value=_safe_value(args),
|
||||
metadata={"tool_name": tool_name, "tool_call_id": tool_call_id},
|
||||
)
|
||||
|
||||
|
||||
def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None,
|
||||
task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
tool_key = tool_call_id or ""
|
||||
observation = None
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
return
|
||||
if tool_key:
|
||||
observation = state.tools.pop(tool_key, None)
|
||||
elif state.tools:
|
||||
_, observation = state.tools.popitem()
|
||||
|
||||
if observation is None:
|
||||
return
|
||||
|
||||
if isinstance(result, str):
|
||||
result_value = _maybe_parse_json_string(result)
|
||||
else:
|
||||
result_value = result
|
||||
result_value = _normalize_payload(result_value, tool_name=tool_name, args=args)
|
||||
|
||||
_end_observation(
|
||||
observation,
|
||||
output=_safe_value(result_value, parse_json_strings=True),
|
||||
metadata={"tool_name": tool_name, "args": _safe_value(args, parse_json_strings=True)},
|
||||
)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
# Register for both hook name variants so the plugin works across
|
||||
# Hermes versions. pre_api_request / post_api_request fire per API
|
||||
# call (preferred); pre_llm_call / post_llm_call fire once per turn.
|
||||
ctx.register_hook("pre_api_request", on_pre_llm_request)
|
||||
ctx.register_hook("post_api_request", on_post_llm_call)
|
||||
ctx.register_hook("pre_llm_call", on_pre_llm_call)
|
||||
ctx.register_hook("post_llm_call", on_post_llm_call)
|
||||
ctx.register_hook("pre_tool_call", on_pre_tool_call)
|
||||
ctx.register_hook("post_tool_call", on_post_tool_call)
|
||||
38
optional-plugins/observability/langfuse/after-install.md
Normal file
38
optional-plugins/observability/langfuse/after-install.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# After installing langfuse
|
||||
|
||||
Langfuse tracing is now installed and enabled for your Hermes profile.
|
||||
|
||||
## Required credentials
|
||||
|
||||
Set these in `~/.hermes/.env` (or via `hermes tools` → Langfuse Observability):
|
||||
|
||||
```bash
|
||||
HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-...
|
||||
HERMES_LANGFUSE_SECRET_KEY=sk-lf-...
|
||||
HERMES_LANGFUSE_BASE_URL=https://cloud.langfuse.com # or your self-hosted URL
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
hermes plugins list # langfuse should appear as enabled
|
||||
hermes chat -q "hello" # then check Langfuse for a "Hermes turn" trace
|
||||
```
|
||||
|
||||
## Optional settings
|
||||
|
||||
```bash
|
||||
HERMES_LANGFUSE_ENV=production # environment tag
|
||||
HERMES_LANGFUSE_RELEASE=v1.0.0 # release tag
|
||||
HERMES_LANGFUSE_SAMPLE_RATE=0.5 # sample 50% of traces
|
||||
HERMES_LANGFUSE_MAX_CHARS=12000 # max chars per field (default: 12000)
|
||||
HERMES_LANGFUSE_DEBUG=true # verbose plugin logging
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
The `langfuse` Python SDK is required. Install it into your Hermes venv:
|
||||
|
||||
```bash
|
||||
pip install langfuse
|
||||
```
|
||||
14
optional-plugins/observability/langfuse/plugin.yaml
Normal file
14
optional-plugins/observability/langfuse/plugin.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
name: langfuse
|
||||
version: "1.0.0"
|
||||
description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Install via: hermes plugins install official/observability/langfuse"
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- HERMES_LANGFUSE_PUBLIC_KEY
|
||||
- HERMES_LANGFUSE_SECRET_KEY
|
||||
hooks:
|
||||
- pre_api_request
|
||||
- post_api_request
|
||||
- pre_llm_call
|
||||
- post_llm_call
|
||||
- pre_tool_call
|
||||
- post_tool_call
|
||||
@@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py
|
||||
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
|
||||
cron = ["croniter>=6.0.0,<7"]
|
||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<1"]
|
||||
cli = ["simple-term-menu>=1.0,<2"]
|
||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||
voice = [
|
||||
|
||||
@@ -43,6 +43,13 @@ AUTHOR_MAP = {
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"teknium@nousresearch.com": "teknium1",
|
||||
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
||||
# Matrix parity salvage batch (April 2026)
|
||||
"sr@samirusani": "samrusani",
|
||||
"angelclaw@AngelMacBook.local": "angel12",
|
||||
"charles@cryptoassetrecovery.com": "charles-brooks",
|
||||
"heathley@Heathley-MacBook-Air.local": "heathley",
|
||||
"adamrummer@gmail.com": "cyclingwithelephants",
|
||||
"nbot@liizfq.top": "liizfq",
|
||||
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
|
||||
"johnnncenaaa77@gmail.com": "johnncenae",
|
||||
"focusflow.app.help@gmail.com": "yes999zc",
|
||||
|
||||
@@ -408,17 +408,17 @@ Common "why is Hermes doing X to my output / tool calls / commands?" toggles —
|
||||
|
||||
### Secret redaction in tool output
|
||||
|
||||
Hermes auto-redacts strings that look like API keys, tokens, and secrets in all tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) so the model never sees raw credentials. If the user is intentionally working with mock tokens, share-management tokens, or their own secrets and the redaction is getting in the way:
|
||||
Secret redaction is **off by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) passes through unmodified. If the user wants Hermes to auto-mask strings that look like API keys, tokens, and secrets before they enter the conversation context and logs:
|
||||
|
||||
```bash
|
||||
hermes config set security.redact_secrets false # disable globally
|
||||
hermes config set security.redact_secrets true # enable globally
|
||||
```
|
||||
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — setting it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets false` in a terminal, then start a new session. This is deliberate — it prevents an LLM from turning off redaction on itself mid-task.
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=true` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets true` in a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
|
||||
Re-enable with:
|
||||
Disable again with:
|
||||
```bash
|
||||
hermes config set security.redact_secrets true
|
||||
hermes config set security.redact_secrets false
|
||||
```
|
||||
|
||||
### PII redaction in gateway messages
|
||||
|
||||
21
skills/creative/humanizer/LICENSE
Normal file
21
skills/creative/humanizer/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Siqi Chen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
577
skills/creative/humanizer/SKILL.md
Normal file
577
skills/creative/humanizer/SKILL.md
Normal file
@@ -0,0 +1,577 @@
|
||||
---
|
||||
name: humanizer
|
||||
description: "Humanize text: strip AI-isms and add real voice."
|
||||
version: 2.5.1
|
||||
author: Siqi Chen (@blader, https://github.com/blader/humanizer), ported by Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [writing, editing, humanize, anti-ai-slop, voice, prose, text]
|
||||
category: creative
|
||||
homepage: https://github.com/blader/humanizer
|
||||
related_skills: [songwriting-and-ai-music]
|
||||
---
|
||||
|
||||
# Humanizer: Remove AI Writing Patterns
|
||||
|
||||
Identify and remove signs of AI-generated text to make writing sound natural and human. Based on Wikipedia's "Signs of AI writing" guide (maintained by WikiProject AI Cleanup), derived from observations of thousands of AI-generated text instances.
|
||||
|
||||
**Key insight:** LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely completion, which is how the telltale patterns below get baked in.
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Load this skill whenever the user asks to:
|
||||
- "humanize", "de-AI", "de-slop", or "un-ChatGPT" a piece of text
|
||||
- rewrite something so it doesn't sound like it was written by an LLM
|
||||
- edit a draft (blog post, essay, PR description, docs, memo, email, tweet, resume bullet) to sound more natural
|
||||
- match their voice in writing they're producing
|
||||
- review text for AI tells before publishing
|
||||
|
||||
Also apply this skill to **your own** output when writing user-facing prose — release notes, PR descriptions, documentation, long-form explanations, summaries. Hermes's baseline voice already strips most of these, but a focused pass catches what slips through.
|
||||
|
||||
## How to use it in Hermes
|
||||
|
||||
The text usually arrives one of three ways:
|
||||
1. **Inline** — user pastes the text directly into the message. Work on it in-place, reply with the rewrite.
|
||||
2. **File** — user points at a file. Use `read_file` to load it, then `patch` or `write_file` to apply edits. For markdown docs in a repo, a targeted `patch` per section is cleaner than rewriting the whole file.
|
||||
3. **Voice calibration sample** — user provides an additional sample of their own writing (inline or by file path) and asks you to match it. Read the sample first, then rewrite. See the Voice Calibration section below.
|
||||
|
||||
Always show the rewrite to the user. For file edits, show a diff or the changed section — don't silently overwrite.
|
||||
|
||||
## Your task
|
||||
|
||||
When given text to humanize:
|
||||
|
||||
1. **Identify AI patterns** — scan for the 29 patterns listed below.
|
||||
2. **Rewrite problematic sections** — replace AI-isms with natural alternatives.
|
||||
3. **Preserve meaning** — keep the core message intact.
|
||||
4. **Maintain voice** — match the intended tone (formal, casual, technical, etc.). If a voice sample was provided, match it specifically.
|
||||
5. **Add soul** — don't just remove bad patterns, inject actual personality. See PERSONALITY AND SOUL below.
|
||||
6. **Do a final anti-AI pass** — ask yourself: "What makes the below so obviously AI generated?" Answer briefly with any remaining tells, then revise one more time.
|
||||
|
||||
|
||||
## Voice Calibration (optional)
|
||||
|
||||
If the user provides a writing sample (their own previous writing), analyze it before rewriting:
|
||||
|
||||
1. **Read the sample first.** Note:
|
||||
- Sentence length patterns (short and punchy? Long and flowing? Mixed?)
|
||||
- Word choice level (casual? academic? somewhere between?)
|
||||
- How they start paragraphs (jump right in? Set context first?)
|
||||
- Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?)
|
||||
- Any recurring phrases or verbal tics
|
||||
- How they handle transitions (explicit connectors? Just start the next point?)
|
||||
|
||||
2. **Match their voice in the rewrite.** Don't just remove AI patterns — replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components."
|
||||
|
||||
3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below).
|
||||
|
||||
### How to provide a sample
|
||||
- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]"
|
||||
- File: "Humanize this text. Use my writing style from [file path] as a reference."
|
||||
|
||||
|
||||
## PERSONALITY AND SOUL
|
||||
|
||||
Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.
|
||||
|
||||
### Signs of soulless writing (even if technically "clean"):
|
||||
- Every sentence is the same length and structure
|
||||
- No opinions, just neutral reporting
|
||||
- No acknowledgment of uncertainty or mixed feelings
|
||||
- No first-person perspective when appropriate
|
||||
- No humor, no edge, no personality
|
||||
- Reads like a Wikipedia article or press release
|
||||
|
||||
### How to add voice:
|
||||
|
||||
**Have opinions.** Don't just report facts — react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
|
||||
|
||||
**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
|
||||
|
||||
**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
|
||||
|
||||
**Use "I" when it fits.** First person isn't unprofessional — it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
|
||||
|
||||
**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
|
||||
|
||||
**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
|
||||
|
||||
### Before (clean but soulless):
|
||||
> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
|
||||
|
||||
### After (has a pulse):
|
||||
> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle — but I keep thinking about those agents working through the night.
|
||||
|
||||
|
||||
## CONTENT PATTERNS
|
||||
|
||||
### 1. Undue Emphasis on Significance, Legacy, and Broader Trends
|
||||
|
||||
**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted
|
||||
|
||||
**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.
|
||||
|
||||
**Before:**
|
||||
> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.
|
||||
|
||||
**After:**
|
||||
> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.
|
||||
|
||||
|
||||
### 2. Undue Emphasis on Notability and Media Coverage
|
||||
|
||||
**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence
|
||||
|
||||
**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.
|
||||
|
||||
**Before:**
|
||||
> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.
|
||||
|
||||
**After:**
|
||||
> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
|
||||
|
||||
|
||||
### 3. Superficial Analyses with -ing Endings
|
||||
|
||||
**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...
|
||||
|
||||
**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.
|
||||
|
||||
**Before:**
|
||||
> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.
|
||||
|
||||
**After:**
|
||||
> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.
|
||||
|
||||
|
||||
### 4. Promotional and Advertisement-like Language
|
||||
|
||||
**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning
|
||||
|
||||
**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.
|
||||
|
||||
**Before:**
|
||||
> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.
|
||||
|
||||
**After:**
|
||||
> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.
|
||||
|
||||
|
||||
### 5. Vague Attributions and Weasel Words
|
||||
|
||||
**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)
|
||||
|
||||
**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.
|
||||
|
||||
**Before:**
|
||||
> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.
|
||||
|
||||
**After:**
|
||||
> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
|
||||
|
||||
|
||||
### 6. Outline-like "Challenges and Future Prospects" Sections
|
||||
|
||||
**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
|
||||
|
||||
**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.
|
||||
|
||||
**Before:**
|
||||
> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.
|
||||
|
||||
**After:**
|
||||
> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.
|
||||
|
||||
|
||||
## LANGUAGE AND GRAMMAR PATTERNS
|
||||
|
||||
### 7. Overused "AI Vocabulary" Words
|
||||
|
||||
**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant
|
||||
|
||||
**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.
|
||||
|
||||
**Before:**
|
||||
> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.
|
||||
|
||||
**After:**
|
||||
> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
|
||||
|
||||
|
||||
### 8. Avoidance of "is"/"are" (Copula Avoidance)
|
||||
|
||||
**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
|
||||
|
||||
**Problem:** LLMs substitute elaborate constructions for simple copulas.
|
||||
|
||||
**Before:**
|
||||
> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.
|
||||
|
||||
**After:**
|
||||
> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.
|
||||
|
||||
|
||||
### 9. Negative Parallelisms and Tailing Negations
|
||||
|
||||
**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause.
|
||||
|
||||
**Before:**
|
||||
> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.
|
||||
|
||||
**After:**
|
||||
> The heavy beat adds to the aggressive tone.
|
||||
|
||||
**Before (tailing negation):**
|
||||
> The options come from the selected item, no guessing.
|
||||
|
||||
**After:**
|
||||
> The options come from the selected item without forcing the user to guess.
|
||||
|
||||
|
||||
### 10. Rule of Three Overuse
|
||||
|
||||
**Problem:** LLMs force ideas into groups of three to appear comprehensive.
|
||||
|
||||
**Before:**
|
||||
> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
|
||||
|
||||
**After:**
|
||||
> The event includes talks and panels. There's also time for informal networking between sessions.
|
||||
|
||||
|
||||
### 11. Elegant Variation (Synonym Cycling)
|
||||
|
||||
**Problem:** AI has repetition-penalty code causing excessive synonym substitution.
|
||||
|
||||
**Before:**
|
||||
> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
|
||||
|
||||
**After:**
|
||||
> The protagonist faces many challenges but eventually triumphs and returns home.
|
||||
|
||||
|
||||
### 12. False Ranges
|
||||
|
||||
**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.
|
||||
|
||||
**Before:**
|
||||
> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.
|
||||
|
||||
**After:**
|
||||
> The book covers the Big Bang, star formation, and current theories about dark matter.
|
||||
|
||||
|
||||
### 13. Passive Voice and Subjectless Fragments
|
||||
|
||||
**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct.
|
||||
|
||||
**Before:**
|
||||
> No configuration file needed. The results are preserved automatically.
|
||||
|
||||
**After:**
|
||||
> You do not need a configuration file. The system preserves the results automatically.
|
||||
|
||||
|
||||
## STYLE PATTERNS
|
||||
|
||||
### 14. Em Dash Overuse
|
||||
|
||||
**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses.
|
||||
|
||||
**Before:**
|
||||
> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.
|
||||
|
||||
**After:**
|
||||
> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents.
|
||||
|
||||
|
||||
### 15. Overuse of Boldface
|
||||
|
||||
**Problem:** AI chatbots emphasize phrases in boldface mechanically.
|
||||
|
||||
**Before:**
|
||||
> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.
|
||||
|
||||
**After:**
|
||||
> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
|
||||
|
||||
|
||||
### 16. Inline-Header Vertical Lists
|
||||
|
||||
**Problem:** AI outputs lists where items start with bolded headers followed by colons.
|
||||
|
||||
**Before:**
|
||||
> - **User Experience:** The user experience has been significantly improved with a new interface.
|
||||
> - **Performance:** Performance has been enhanced through optimized algorithms.
|
||||
> - **Security:** Security has been strengthened with end-to-end encryption.
|
||||
|
||||
**After:**
|
||||
> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.
|
||||
|
||||
|
||||
### 17. Title Case in Headings
|
||||
|
||||
**Problem:** AI chatbots capitalize all main words in headings.
|
||||
|
||||
**Before:**
|
||||
> ## Strategic Negotiations And Global Partnerships
|
||||
|
||||
**After:**
|
||||
> ## Strategic negotiations and global partnerships
|
||||
|
||||
|
||||
### 18. Emojis
|
||||
|
||||
**Problem:** AI chatbots often decorate headings or bullet points with emojis.
|
||||
|
||||
**Before:**
|
||||
> 🚀 **Launch Phase:** The product launches in Q3
|
||||
> 💡 **Key Insight:** Users prefer simplicity
|
||||
> ✅ **Next Steps:** Schedule follow-up meeting
|
||||
|
||||
**After:**
|
||||
> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.
|
||||
|
||||
|
||||
### 19. Curly Quotation Marks
|
||||
|
||||
**Problem:** ChatGPT uses curly quotes ("...") instead of straight quotes ("...").
|
||||
|
||||
**Before:**
|
||||
> He said "the project is on track" but others disagreed.
|
||||
|
||||
**After:**
|
||||
> He said "the project is on track" but others disagreed.
|
||||
|
||||
|
||||
## COMMUNICATION PATTERNS
|
||||
|
||||
### 20. Collaborative Communication Artifacts
|
||||
|
||||
**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
|
||||
|
||||
**Problem:** Text meant as chatbot correspondence gets pasted as content.
|
||||
|
||||
**Before:**
|
||||
> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
|
||||
|
||||
**After:**
|
||||
> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
|
||||
|
||||
|
||||
### 21. Knowledge-Cutoff Disclaimers
|
||||
|
||||
**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
|
||||
|
||||
**Problem:** AI disclaimers about incomplete information get left in text.
|
||||
|
||||
**Before:**
|
||||
> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
|
||||
|
||||
**After:**
|
||||
> The company was founded in 1994, according to its registration documents.
|
||||
|
||||
|
||||
### 22. Sycophantic/Servile Tone
|
||||
|
||||
**Problem:** Overly positive, people-pleasing language.
|
||||
|
||||
**Before:**
|
||||
> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.
|
||||
|
||||
**After:**
|
||||
> The economic factors you mentioned are relevant here.
|
||||
|
||||
|
||||
## FILLER AND HEDGING
|
||||
|
||||
### 23. Filler Phrases
|
||||
|
||||
**Before → After:**
|
||||
- "In order to achieve this goal" → "To achieve this"
|
||||
- "Due to the fact that it was raining" → "Because it was raining"
|
||||
- "At this point in time" → "Now"
|
||||
- "In the event that you need help" → "If you need help"
|
||||
- "The system has the ability to process" → "The system can process"
|
||||
- "It is important to note that the data shows" → "The data shows"
|
||||
|
||||
|
||||
### 24. Excessive Hedging
|
||||
|
||||
**Problem:** Over-qualifying statements.
|
||||
|
||||
**Before:**
|
||||
> It could potentially possibly be argued that the policy might have some effect on outcomes.
|
||||
|
||||
**After:**
|
||||
> The policy may affect outcomes.
|
||||
|
||||
|
||||
### 25. Generic Positive Conclusions
|
||||
|
||||
**Problem:** Vague upbeat endings.
|
||||
|
||||
**Before:**
|
||||
> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.
|
||||
|
||||
**After:**
|
||||
> The company plans to open two more locations next year.
|
||||
|
||||
|
||||
### 26. Hyphenated Word Pair Overuse
|
||||
|
||||
**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end
|
||||
|
||||
**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate.
|
||||
|
||||
**Before:**
|
||||
> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented.
|
||||
|
||||
**After:**
|
||||
> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented.
|
||||
|
||||
|
||||
### 27. Persuasive Authority Tropes
|
||||
|
||||
**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter
|
||||
|
||||
**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony.
|
||||
|
||||
**Before:**
|
||||
> The real question is whether teams can adapt. At its core, what really matters is organizational readiness.
|
||||
|
||||
**After:**
|
||||
> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits.
|
||||
|
||||
|
||||
### 28. Signposting and Announcements
|
||||
|
||||
**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado
|
||||
|
||||
**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel.
|
||||
|
||||
**Before:**
|
||||
> Let's dive into how caching works in Next.js. Here's what you need to know.
|
||||
|
||||
**After:**
|
||||
> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache.
|
||||
|
||||
|
||||
### 29. Fragmented Headers
|
||||
|
||||
**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins.
|
||||
|
||||
**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded.
|
||||
|
||||
**Before:**
|
||||
> ## Performance
|
||||
>
|
||||
> Speed matters.
|
||||
>
|
||||
> When users hit a slow page, they leave.
|
||||
|
||||
**After:**
|
||||
> ## Performance
|
||||
>
|
||||
> When users hit a slow page, they leave.
|
||||
|
||||
---
|
||||
|
||||
## Process
|
||||
|
||||
1. Read the input text carefully (use `read_file` if it's a file).
|
||||
2. Identify all instances of the patterns above.
|
||||
3. Rewrite each problematic section.
|
||||
4. Ensure the revised text:
|
||||
- Sounds natural when read aloud
|
||||
- Varies sentence structure naturally
|
||||
- Uses specific details over vague claims
|
||||
- Maintains appropriate tone for context
|
||||
- Uses simple constructions (is/are/has) where appropriate
|
||||
5. Present a draft humanized version.
|
||||
6. Prompt yourself: "What makes the below so obviously AI generated?"
|
||||
7. Answer briefly with the remaining tells (if any).
|
||||
8. Prompt yourself: "Now make it not obviously AI generated."
|
||||
9. Present the final version (revised after the audit).
|
||||
10. If the text came from a file, apply the edit with `patch` (targeted) or `write_file` (full rewrite) and show the user what changed.
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide:
|
||||
1. Draft rewrite
|
||||
2. "What makes the below so obviously AI generated?" (brief bullets)
|
||||
3. Final rewrite
|
||||
4. A brief summary of changes made (optional, if helpful)
|
||||
|
||||
|
||||
## Full Example
|
||||
|
||||
**Before (AI-sounding):**
|
||||
> Great question! Here is an essay on this topic. I hope this helps!
|
||||
>
|
||||
> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
|
||||
>
|
||||
> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
|
||||
>
|
||||
> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
|
||||
>
|
||||
> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
|
||||
> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
|
||||
> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
|
||||
>
|
||||
> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
|
||||
>
|
||||
> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you'd like me to expand on any section!
|
||||
|
||||
**Draft rewrite:**
|
||||
> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
|
||||
>
|
||||
> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
|
||||
>
|
||||
> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
|
||||
>
|
||||
> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
|
||||
>
|
||||
> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.
|
||||
|
||||
**What makes the below so obviously AI generated?**
|
||||
- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs).
|
||||
- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced.
|
||||
- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking.
|
||||
|
||||
**Now make it not obviously AI generated.**
|
||||
> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
|
||||
>
|
||||
> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
|
||||
>
|
||||
> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable.
|
||||
>
|
||||
> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing.
|
||||
|
||||
**Changes made:**
|
||||
- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
|
||||
- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
|
||||
- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
|
||||
- Removed vague attributions ("Industry observers")
|
||||
- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
|
||||
- Removed negative parallelism ("It's not just X; it's Y")
|
||||
- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
|
||||
- Removed false ranges ("from X to Y, from A to B")
|
||||
- Removed em dashes, emojis, boldface headers, and curly quotes
|
||||
- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
|
||||
- Removed formulaic challenges section ("Despite challenges... continues to thrive")
|
||||
- Removed knowledge-cutoff hedging ("While specific details are limited...")
|
||||
- Removed excessive hedging ("could potentially be argued that... might have some")
|
||||
- Removed filler phrases and persuasive framing ("In order to", "At its core")
|
||||
- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
|
||||
- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders)
|
||||
|
||||
|
||||
## Attribution
|
||||
|
||||
This skill is ported from [blader/humanizer](https://github.com/blader/humanizer) (MIT licensed), which is itself based on [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.
|
||||
|
||||
Original author: Siqi Chen ([@blader](https://github.com/blader)). Original repo: https://github.com/blader/humanizer (version 2.5.1). Ported to Hermes Agent with Hermes-native tool references (`read_file`, `patch`, `write_file`) and guidance for when to load the skill; the 29 patterns, personality/soul section, and full worked example are preserved verbatim from the source. Original MIT license preserved in the `LICENSE` file alongside this `SKILL.md`.
|
||||
|
||||
Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
|
||||
105
tests/agent/test_bedrock_1m_context.py
Normal file
105
tests/agent/test_bedrock_1m_context.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for the 1M-context beta header on AWS Bedrock Claude models.
|
||||
|
||||
Claude Opus 4.6/4.7 and Sonnet 4.6 support a 1M context window, but on AWS
|
||||
Bedrock (and Azure AI Foundry) that window is still gated behind the
|
||||
``context-1m-2025-08-07`` beta header as of 2026-04. Without it, Bedrock
|
||||
caps these models at 200K even though ``model_metadata.py`` advertises 1M.
|
||||
|
||||
These tests guard the invariant that the header is always emitted on the
|
||||
Bedrock client path, and that it survives the MiniMax bearer-auth strip.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestBedrockContext1MBeta:
|
||||
"""``context-1m-2025-08-07`` must reach Bedrock Claude requests."""
|
||||
|
||||
def test_common_betas_includes_1m(self):
|
||||
from agent.anthropic_adapter import _COMMON_BETAS, _CONTEXT_1M_BETA
|
||||
|
||||
assert _CONTEXT_1M_BETA == "context-1m-2025-08-07"
|
||||
assert _CONTEXT_1M_BETA in _COMMON_BETAS
|
||||
|
||||
def test_common_betas_for_native_anthropic_includes_1m(self):
|
||||
"""Native Anthropic endpoints (and Bedrock with empty base_url) get 1M."""
|
||||
from agent.anthropic_adapter import (
|
||||
_common_betas_for_base_url,
|
||||
_CONTEXT_1M_BETA,
|
||||
)
|
||||
|
||||
assert _CONTEXT_1M_BETA in _common_betas_for_base_url(None)
|
||||
assert _CONTEXT_1M_BETA in _common_betas_for_base_url("")
|
||||
assert _CONTEXT_1M_BETA in _common_betas_for_base_url(
|
||||
"https://api.anthropic.com"
|
||||
)
|
||||
|
||||
def test_common_betas_strips_1m_for_minimax(self):
|
||||
"""MiniMax bearer-auth endpoints host their own models — strip 1M beta."""
|
||||
from agent.anthropic_adapter import (
|
||||
_common_betas_for_base_url,
|
||||
_CONTEXT_1M_BETA,
|
||||
)
|
||||
|
||||
for url in (
|
||||
"https://api.minimax.io/anthropic",
|
||||
"https://api.minimaxi.com/anthropic",
|
||||
):
|
||||
betas = _common_betas_for_base_url(url)
|
||||
assert _CONTEXT_1M_BETA not in betas, (
|
||||
f"1M beta must be stripped for MiniMax bearer endpoint {url}"
|
||||
)
|
||||
# Other betas still present
|
||||
assert "interleaved-thinking-2025-05-14" in betas
|
||||
|
||||
def test_build_anthropic_bedrock_client_sends_1m_beta(self):
|
||||
"""AnthropicBedrock client must carry the 1M beta in default_headers.
|
||||
|
||||
This is the load-bearing assertion for the reported bug:
|
||||
without this header Bedrock serves Opus 4.6/4.7 with a 200K cap.
|
||||
"""
|
||||
import agent.anthropic_adapter as adapter
|
||||
|
||||
fake_sdk = MagicMock()
|
||||
fake_sdk.AnthropicBedrock = MagicMock()
|
||||
|
||||
with patch.object(adapter, "_anthropic_sdk", fake_sdk):
|
||||
adapter.build_anthropic_bedrock_client(region="us-west-2")
|
||||
|
||||
call_kwargs = fake_sdk.AnthropicBedrock.call_args.kwargs
|
||||
assert call_kwargs["aws_region"] == "us-west-2"
|
||||
|
||||
default_headers = call_kwargs.get("default_headers") or {}
|
||||
beta_header = default_headers.get("anthropic-beta", "")
|
||||
assert "context-1m-2025-08-07" in beta_header, (
|
||||
"Bedrock client must send context-1m-2025-08-07 or Opus 4.6/4.7 "
|
||||
"silently caps at 200K context"
|
||||
)
|
||||
# Other common betas still present — no regression.
|
||||
assert "interleaved-thinking-2025-05-14" in beta_header
|
||||
assert "fine-grained-tool-streaming-2025-05-14" in beta_header
|
||||
|
||||
def test_build_anthropic_kwargs_includes_1m_for_bedrock_fastmode(self):
|
||||
"""Fast-mode requests (per-request extra_headers) still include 1M beta.
|
||||
|
||||
Per-request extra_headers override client-level default_headers, so
|
||||
the fast-mode path must re-include everything in _COMMON_BETAS.
|
||||
"""
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-7",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
max_tokens=1024,
|
||||
reasoning_config=None,
|
||||
is_oauth=False,
|
||||
# Empty base_url mirrors AnthropicBedrock (no HTTP base URL)
|
||||
base_url=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
beta_header = kwargs.get("extra_headers", {}).get("anthropic-beta", "")
|
||||
assert "context-1m-2025-08-07" in beta_header, (
|
||||
"fast-mode extra_headers must carry the 1M beta or it overrides "
|
||||
"client-level default_headers and Bedrock drops back to 200K"
|
||||
)
|
||||
49
tests/e2e/matrix_xsign_bootstrap/README.md
Normal file
49
tests/e2e/matrix_xsign_bootstrap/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Matrix cross-signing bootstrap — E2E test
|
||||
|
||||
Self-contained end-to-end test for the auto-bootstrap behavior added in
|
||||
`gateway/platforms/matrix.py`. Spins up a real Continuwuity homeserver
|
||||
in Docker, registers a fresh bot, runs the patched bootstrap path
|
||||
against it, and asserts:
|
||||
|
||||
1. Cross-signing keys get published with **unpadded** base64 keyids
|
||||
(the bug this PR fixes — padded keyids are silently rejected by
|
||||
matrix-rust-sdk in Element).
|
||||
2. On a second startup with the same crypto store, bootstrap is
|
||||
skipped.
|
||||
3. When `MATRIX_RECOVERY_KEY` is set, the existing recovery-key path
|
||||
takes precedence and no fresh bootstrap happens.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
|
||||
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
The `down -v` step removes the persistent volume so the next run gets
|
||||
a fresh homeserver — important because Continuwuity's one-time admin
|
||||
registration token is only valid before the first user is created.
|
||||
|
||||
## Port
|
||||
|
||||
The compose binds Continuwuity to `127.0.0.1:26167` by default. Override
|
||||
with `HOMESERVER_HOST_PORT=NNNNN docker compose up -d` if that port is
|
||||
busy locally.
|
||||
|
||||
## What the test exercises
|
||||
|
||||
The test mirrors the bootstrap snippet from
|
||||
`gateway/platforms/matrix.py` (the "if MATRIX_RECOVERY_KEY else
|
||||
get_own_cross_signing_public_keys / generate_recovery_key" branch)
|
||||
inline so it runs without importing the entire hermes gateway and its
|
||||
many dependencies. **If the source diverges from what's in
|
||||
`_connect_with_bootstrap`, this test must be updated to match.** A
|
||||
small price for not requiring the full hermes-agent runtime in CI.
|
||||
|
||||
## Skipped when
|
||||
|
||||
- `mautrix` Python package is not installed
|
||||
- The homeserver isn't reachable at `$E2E_MATRIX_HS` (default
|
||||
`http://127.0.0.1:26167`)
|
||||
21
tests/e2e/matrix_xsign_bootstrap/docker-compose.yml
Normal file
21
tests/e2e/matrix_xsign_bootstrap/docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
homeserver:
|
||||
image: ghcr.io/continuwuity/continuwuity:latest
|
||||
environment:
|
||||
CONTINUWUITY_SERVER_NAME: localhost
|
||||
CONTINUWUITY_DATABASE_PATH: /var/lib/conduwuit/conduwuit.db
|
||||
CONTINUWUITY_PORT: "6167"
|
||||
CONTINUWUITY_ADDRESS: "0.0.0.0"
|
||||
CONTINUWUITY_ALLOW_REGISTRATION: "true"
|
||||
CONTINUWUITY_REGISTRATION_TOKEN: testreg
|
||||
CONTINUWUITY_ALLOW_FEDERATION: "false"
|
||||
CONTINUWUITY_TRUSTED_SERVERS: "[]"
|
||||
CONTINUWUITY_LOG: "warn,conduwuit=info"
|
||||
CONTINUWUITY_ALLOW_CHECK_FOR_UPDATES: "false"
|
||||
ports:
|
||||
- "127.0.0.1:${HOMESERVER_HOST_PORT:-26167}:6167"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/6167 && echo -e 'GET /_matrix/client/versions HTTP/1.0\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q '200 OK' || exit 1"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
333
tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
Normal file
333
tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""End-to-end test for Matrix cross-signing auto-bootstrap.
|
||||
|
||||
Spins a real Continuwuity homeserver in docker, registers a fresh bot,
|
||||
runs the patched ``MatrixAdapter.connect()`` against it, and asserts:
|
||||
|
||||
1. cross-signing keys get published with **unpadded** base64 keyids
|
||||
(the bug this PR fixes — padded keyids are silently rejected by
|
||||
matrix-rust-sdk in Element);
|
||||
2. on a second startup with the same crypto store, bootstrap is
|
||||
skipped (``get_own_cross_signing_public_keys`` finds the keys);
|
||||
3. the bot's current device is signed by the new SSK, so Element
|
||||
considers the device "verified by its owner".
|
||||
|
||||
Self-contained: ``docker compose up -d`` brings up Continuwuity on
|
||||
127.0.0.1:26167; this script registers a fresh bot using the
|
||||
homeserver's one-time admin registration token (printed once at first
|
||||
boot, parsed from the container logs); then drives the gateway code.
|
||||
|
||||
Run from repo root::
|
||||
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
|
||||
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
|
||||
|
||||
Skipped automatically if mautrix isn't installed or the homeserver
|
||||
isn't reachable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
HS = os.environ.get("E2E_MATRIX_HS", "http://127.0.0.1:26167")
|
||||
COMPOSE_DIR = Path(__file__).parent
|
||||
CONTAINER_NAME = "matrix_xsign_bootstrap-homeserver-1"
|
||||
|
||||
|
||||
def _hs_reachable() -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(f"{HS}/_matrix/client/versions", timeout=2).read()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _first_time_token() -> str | None:
|
||||
"""Continuwuity prints a one-time registration token on first boot.
|
||||
|
||||
The configured CONTINUWUITY_REGISTRATION_TOKEN does NOT activate
|
||||
until an account exists, so we have to pull this token out of the
|
||||
docker logs to bootstrap the very first user.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["docker", "logs", CONTAINER_NAME],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout + subprocess.run(
|
||||
["docker", "logs", CONTAINER_NAME],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stderr
|
||||
except Exception:
|
||||
return None
|
||||
cleaned = re.sub(r"\x1b\[[0-9;]*m", "", out)
|
||||
m = re.search(r"registration token ([A-Za-z0-9]+)", cleaned)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _post_json(url: str, body: dict, headers: dict | None = None) -> tuple[int, dict]:
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
r = urllib.request.urlopen(req)
|
||||
return r.status, json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode())
|
||||
|
||||
|
||||
CONFIG_REG_TOKEN = "testreg" # matches docker-compose.yml
|
||||
|
||||
|
||||
def _register_bot(*, prefer_token: str = CONFIG_REG_TOKEN, fallback_token: str | None = None) -> dict:
|
||||
"""Register a fresh bot. Tries the configured token first; falls back to
|
||||
the homeserver's one-time admin token (only valid until the first user
|
||||
is created)."""
|
||||
user = "bot" + secrets.token_hex(3)
|
||||
password = secrets.token_urlsafe(20)
|
||||
last_err = None
|
||||
for tok in (prefer_token, fallback_token):
|
||||
if tok is None:
|
||||
continue
|
||||
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {})
|
||||
if st != 401 or "session" not in b:
|
||||
last_err = (st, b); continue
|
||||
session = b["session"]
|
||||
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {
|
||||
"auth": {"type": "m.login.registration_token", "token": tok, "session": session},
|
||||
"username": user, "password": password,
|
||||
"initial_device_display_name": "e2e-bootstrap-test",
|
||||
})
|
||||
if st == 200:
|
||||
return b
|
||||
last_err = (st, b)
|
||||
raise AssertionError(f"register failed for both tokens: {last_err}")
|
||||
|
||||
|
||||
def _query_keys(token: str, mxid: str) -> dict:
|
||||
return _post_json(
|
||||
f"{HS}/_matrix/client/v3/keys/query",
|
||||
{"device_keys": {mxid: []}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(_hs_reachable(), f"homeserver not reachable at {HS}")
|
||||
class XsignBootstrapE2E(unittest.IsolatedAsyncioTestCase):
|
||||
"""Drive the patched MatrixAdapter.connect() against real continuwuity."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
import mautrix # noqa: F401
|
||||
except ImportError:
|
||||
raise unittest.SkipTest("mautrix not installed")
|
||||
cls.first_tok = _first_time_token()
|
||||
# If no user has ever been created, the configured `testreg` token
|
||||
# won't activate yet — burn the one-time admin token first to
|
||||
# bootstrap the homeserver into a usable state.
|
||||
if cls.first_tok:
|
||||
try:
|
||||
_register_bot(prefer_token=cls.first_tok, fallback_token=None)
|
||||
except AssertionError:
|
||||
pass # Already burnt previously; testreg should now work.
|
||||
|
||||
async def _connect_with_bootstrap(self, creds: dict, store_dir: Path) -> tuple[list[str], str | None]:
|
||||
"""Drive matrix.py's bootstrap branch directly.
|
||||
|
||||
We import the gateway module and execute the same OlmMachine init +
|
||||
bootstrap sequence, capturing log lines so we can assert what fired.
|
||||
Returns (log_lines, recovery_key_or_None).
|
||||
"""
|
||||
from mautrix.api import HTTPAPI
|
||||
from mautrix.client import Client
|
||||
from mautrix.client.state_store.memory import MemoryStateStore
|
||||
from mautrix.crypto import OlmMachine, PgCryptoStore
|
||||
from mautrix.types import TrustState
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
# The actual bootstrap snippet from gateway/platforms/matrix.py
|
||||
# (copied so we can run it without importing the full hermes
|
||||
# gateway and its many deps). If the source code drifts from this,
|
||||
# the test should be updated to match.
|
||||
log_lines: list[str] = []
|
||||
captured_recovery_key: str | None = None
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record):
|
||||
log_lines.append(self.format(record))
|
||||
|
||||
logger = logging.getLogger("e2e.bootstrap")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
handler = _Capture()
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
|
||||
client = Client(
|
||||
mxid=creds["user_id"], api=api,
|
||||
device_id=creds["device_id"], state_store=MemoryStateStore(),
|
||||
)
|
||||
client.api.token = creds["access_token"]
|
||||
|
||||
store_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = store_dir / "crypto.db"
|
||||
crypto_db = Database.create(f"sqlite:///{db_path}", upgrade_table=PgCryptoStore.upgrade_table)
|
||||
await crypto_db.start()
|
||||
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
|
||||
await crypto_store.open()
|
||||
|
||||
olm = OlmMachine(client, crypto_store, MemoryStateStore())
|
||||
olm.share_keys_min_trust = TrustState.UNVERIFIED
|
||||
olm.send_keys_min_trust = TrustState.UNVERIFIED
|
||||
await olm.load()
|
||||
|
||||
# --- The patched bootstrap block, mirrored from matrix.py ---
|
||||
recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
|
||||
if recovery_key:
|
||||
try:
|
||||
await olm.verify_with_recovery_key(recovery_key)
|
||||
logger.info("Matrix: cross-signing verified via recovery key")
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: recovery key verification failed: %s", exc)
|
||||
else:
|
||||
try:
|
||||
own_xsign = await olm.get_own_cross_signing_public_keys()
|
||||
except Exception as exc:
|
||||
own_xsign = None
|
||||
logger.warning("Matrix: cross-signing key lookup failed: %s", exc)
|
||||
if own_xsign is None:
|
||||
try:
|
||||
new_recovery_key = await olm.generate_recovery_key()
|
||||
captured_recovery_key = new_recovery_key
|
||||
logger.warning(
|
||||
"Matrix: bootstrapped cross-signing for %s. "
|
||||
"SAVE THIS RECOVERY KEY: %s",
|
||||
client.mxid, new_recovery_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: cross-signing bootstrap failed: %s", exc)
|
||||
|
||||
# --- /end patched block ---
|
||||
# Clean teardown — without this the asyncio loop never exits.
|
||||
await crypto_db.stop()
|
||||
await api.session.close()
|
||||
return log_lines, captured_recovery_key
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.creds = _register_bot(prefer_token=CONFIG_REG_TOKEN, fallback_token=self.first_tok)
|
||||
self.creds["homeserver"] = HS
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="e2e-xsign-"))
|
||||
# mautrix.generate_recovery_key requires account.shared, which means
|
||||
# we must share device keys (one-time keys) first. Do that via a
|
||||
# short bootstrap to publish device keys.
|
||||
await self._publish_device_keys(self.creds, self.tmp)
|
||||
|
||||
async def _publish_device_keys(self, creds, store_dir):
|
||||
"""Tiny helper: open OlmMachine, share device keys, close."""
|
||||
from mautrix.api import HTTPAPI
|
||||
from mautrix.client import Client
|
||||
from mautrix.client.state_store.memory import MemoryStateStore
|
||||
from mautrix.crypto import OlmMachine, PgCryptoStore
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
|
||||
client = Client(mxid=creds["user_id"], api=api, device_id=creds["device_id"],
|
||||
state_store=MemoryStateStore())
|
||||
store_dir.mkdir(parents=True, exist_ok=True)
|
||||
crypto_db = Database.create(f"sqlite:///{store_dir / 'crypto.db'}",
|
||||
upgrade_table=PgCryptoStore.upgrade_table)
|
||||
await crypto_db.start()
|
||||
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
|
||||
await crypto_store.open()
|
||||
olm = OlmMachine(client, crypto_store, MemoryStateStore())
|
||||
await olm.load()
|
||||
await olm.share_keys() # publishes device keys (precondition for generate_recovery_key)
|
||||
await crypto_db.stop()
|
||||
await api.session.close()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
async def test_bootstrap_publishes_unpadded_keys(self):
|
||||
"""Fresh bot → bootstrap fires, keys published unpadded, device signed."""
|
||||
log_lines, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
# 1. Bootstrap must have produced a recovery key
|
||||
self.assertIsNotNone(rec_key, "expected recovery key from bootstrap")
|
||||
self.assertTrue(any("bootstrapped cross-signing" in l for l in log_lines),
|
||||
f"expected bootstrap log line, got: {log_lines}")
|
||||
# 2. Homeserver should now serve a master + ssk for the bot
|
||||
d = _query_keys(self.creds["access_token"], self.creds["user_id"])
|
||||
self.assertIn(self.creds["user_id"], d.get("master_keys", {}),
|
||||
"no master_keys after bootstrap")
|
||||
self.assertIn(self.creds["user_id"], d.get("self_signing_keys", {}),
|
||||
"no self_signing_keys after bootstrap")
|
||||
# 3. The keyids must be UNPADDED (this is the bug this PR exists to fix)
|
||||
master_kid = next(iter(d["master_keys"][self.creds["user_id"]]["keys"]))
|
||||
ssk_kid = next(iter(d["self_signing_keys"][self.creds["user_id"]]["keys"]))
|
||||
self.assertFalse(master_kid.endswith("="),
|
||||
f"master keyid is padded: {master_kid!r}")
|
||||
self.assertFalse(ssk_kid.endswith("="),
|
||||
f"ssk keyid is padded: {ssk_kid!r}")
|
||||
# 4. The current device must be signed by the new SSK
|
||||
dev = d["device_keys"][self.creds["user_id"]][self.creds["device_id"]]
|
||||
sig_kids = list(dev["signatures"][self.creds["user_id"]].keys())
|
||||
self.assertIn(ssk_kid, sig_kids,
|
||||
f"device {self.creds['device_id']} not signed by new SSK; "
|
||||
f"signatures: {sig_kids}")
|
||||
|
||||
async def test_second_startup_skips_bootstrap(self):
|
||||
"""Second startup with same crypto store → no second recovery key."""
|
||||
# First connect bootstraps.
|
||||
_, rec1 = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
self.assertIsNotNone(rec1, "first connect should have bootstrapped")
|
||||
# Second connect on same crypto store should NOT re-bootstrap.
|
||||
log2, rec2 = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
self.assertIsNone(rec2, f"second connect re-bootstrapped! logs: {log2}")
|
||||
self.assertFalse(any("bootstrapped cross-signing" in l for l in log2),
|
||||
f"second connect re-bootstrapped! logs: {log2}")
|
||||
|
||||
async def test_recovery_key_path_takes_precedence(self):
|
||||
"""If MATRIX_RECOVERY_KEY is set, no fresh bootstrap happens."""
|
||||
# First, bootstrap to get a real recovery key.
|
||||
_, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
self.assertIsNotNone(rec_key)
|
||||
# Fresh store directory + recovery key set in env: must take the
|
||||
# verify_with_recovery_key path, NOT bootstrap a new identity.
|
||||
fresh_store = Path(tempfile.mkdtemp(prefix="e2e-xsign-fresh-"))
|
||||
try:
|
||||
await self._publish_device_keys(self.creds, fresh_store)
|
||||
os.environ["MATRIX_RECOVERY_KEY"] = rec_key
|
||||
try:
|
||||
log, rec2 = await self._connect_with_bootstrap(self.creds, fresh_store)
|
||||
self.assertIsNone(rec2, "bootstrap fired despite MATRIX_RECOVERY_KEY being set")
|
||||
self.assertTrue(
|
||||
any("verified via recovery key" in l for l in log),
|
||||
f"expected recovery-key verify log, got: {log}",
|
||||
)
|
||||
finally:
|
||||
del os.environ["MATRIX_RECOVERY_KEY"]
|
||||
finally:
|
||||
shutil.rmtree(fresh_store, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -9,6 +9,7 @@ import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
|
||||
def _make_fake_mautrix():
|
||||
@@ -1204,6 +1205,40 @@ class TestMatrixSyncLoop:
|
||||
fake_client.handle_sync.assert_called_once()
|
||||
mock_sync_store.put_next_batch.assert_awaited_once_with("s1234")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_loop_reconciles_pending_invites(self):
|
||||
"""Pending rooms.invite entries should be joined if callbacks were missed."""
|
||||
adapter = _make_adapter()
|
||||
adapter._closing = False
|
||||
|
||||
async def _sync_once(**kwargs):
|
||||
adapter._closing = True
|
||||
return {
|
||||
"rooms": {
|
||||
"join": {"!joined:example.org": {}},
|
||||
"invite": {"!invited:example.org": {}},
|
||||
},
|
||||
"next_batch": "s1234",
|
||||
}
|
||||
|
||||
mock_sync_store = MagicMock()
|
||||
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
|
||||
mock_sync_store.put_next_batch = AsyncMock()
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.sync = AsyncMock(side_effect=_sync_once)
|
||||
fake_client.join_room = AsyncMock()
|
||||
fake_client.sync_store = mock_sync_store
|
||||
fake_client.handle_sync = MagicMock(return_value=[])
|
||||
adapter._client = fake_client
|
||||
|
||||
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
||||
await adapter._sync_loop()
|
||||
|
||||
fake_client.join_room.assert_awaited_once()
|
||||
assert "!joined:example.org" in adapter._joined_rooms
|
||||
assert "!invited:example.org" in adapter._joined_rooms
|
||||
|
||||
|
||||
class TestMatrixUploadAndSend:
|
||||
@pytest.mark.asyncio
|
||||
@@ -1862,6 +1897,81 @@ class TestMatrixReadReceipts:
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Media normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixImageOnlyMediaNormalization:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._client = MagicMock()
|
||||
self.adapter._client.download_media = AsyncMock(return_value=None)
|
||||
self.adapter._is_dm_room = AsyncMock(return_value=True)
|
||||
self.adapter._get_display_name = AsyncMock(return_value="Alice")
|
||||
self.adapter._background_read_receipt = MagicMock()
|
||||
self.adapter._mxc_to_http = (
|
||||
lambda url: "https://matrix.example.org/_matrix/media/v3/download/example/30.png"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_only_filename_body_is_not_forwarded_as_text(self):
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._handle_media_message(
|
||||
room_id="!room:example.org",
|
||||
sender="@alice:example.org",
|
||||
event_id="$image1",
|
||||
event_ts=0.0,
|
||||
source_content={
|
||||
"msgtype": "m.image",
|
||||
"body": "30.png",
|
||||
"url": "mxc://example/30.png",
|
||||
"info": {"mimetype": "image/png"},
|
||||
},
|
||||
relates_to={},
|
||||
msgtype="m.image",
|
||||
)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.text == ""
|
||||
assert captured_event.media_urls == [
|
||||
"https://matrix.example.org/_matrix/media/v3/download/example/30.png"
|
||||
]
|
||||
assert captured_event.message_type == MessageType.PHOTO
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_caption_text_is_preserved(self):
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._handle_media_message(
|
||||
room_id="!room:example.org",
|
||||
sender="@alice:example.org",
|
||||
event_id="$image2",
|
||||
event_ts=0.0,
|
||||
source_content={
|
||||
"msgtype": "m.image",
|
||||
"body": "Please describe this chart",
|
||||
"url": "mxc://example/30.png",
|
||||
"info": {"mimetype": "image/png"},
|
||||
},
|
||||
relates_to={},
|
||||
msgtype="m.image",
|
||||
)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.text == "Please describe this chart"
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2099,3 +2209,139 @@ class TestMatrixOnRoomMessageFilter:
|
||||
ev = self._mk_event(sender="@alice:example.org", body="hello bot")
|
||||
await self.adapter._on_room_message(ev)
|
||||
self.adapter._handle_text_message.assert_awaited_once()
|
||||
# ---------------------------------------------------------------------------
|
||||
# DM auto-thread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixDmAutoThread:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._is_dm_room = AsyncMock(return_value=True)
|
||||
self.adapter._get_display_name = AsyncMock(return_value="Alice")
|
||||
self.adapter._background_read_receipt = MagicMock()
|
||||
# Disable require_mention so DMs pass gating
|
||||
self.adapter._require_mention = False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_auto_thread_enabled_creates_thread(self):
|
||||
"""When dm_auto_thread is True, DM messages get auto-threaded."""
|
||||
self.adapter._dm_auto_thread = True
|
||||
|
||||
ctx = await self.adapter._resolve_message_context(
|
||||
room_id="!dm:ex",
|
||||
sender="@alice:ex",
|
||||
event_id="$ev1",
|
||||
body="hello",
|
||||
source_content={"body": "hello"},
|
||||
relates_to={},
|
||||
)
|
||||
|
||||
assert ctx is not None
|
||||
_body, _is_dm, _chat_type, thread_id, _display, _source = ctx
|
||||
assert thread_id == "$ev1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_auto_thread_disabled_no_thread(self):
|
||||
"""When dm_auto_thread is False (default), DMs have no auto-thread."""
|
||||
self.adapter._dm_auto_thread = False
|
||||
|
||||
ctx = await self.adapter._resolve_message_context(
|
||||
room_id="!dm:ex",
|
||||
sender="@alice:ex",
|
||||
event_id="$ev2",
|
||||
body="hello",
|
||||
source_content={"body": "hello"},
|
||||
relates_to={},
|
||||
)
|
||||
|
||||
assert ctx is not None
|
||||
_body, _is_dm, _chat_type, thread_id, _display, _source = ctx
|
||||
assert thread_id is None
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixProxyConfig:
|
||||
"""Verify that MatrixAdapter resolves and propagates proxy settings."""
|
||||
|
||||
def _make_adapter(self, monkeypatch, proxy_env=None):
|
||||
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
||||
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
||||
# Clear generic proxy vars so they don't leak from the host
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
|
||||
"https_proxy", "http_proxy", "all_proxy", "MATRIX_PROXY"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
if proxy_env:
|
||||
for k, v in proxy_env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
with patch.dict("sys.modules", _make_fake_mautrix()):
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
cfg = PlatformConfig(enabled=True, token="syt_test",
|
||||
extra={"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@bot:example.org"})
|
||||
return MatrixAdapter(cfg)
|
||||
|
||||
def test_no_proxy_by_default(self, monkeypatch):
|
||||
adapter = self._make_adapter(monkeypatch)
|
||||
assert adapter._proxy_url is None
|
||||
|
||||
def test_matrix_proxy_env_var(self, monkeypatch):
|
||||
adapter = self._make_adapter(monkeypatch,
|
||||
proxy_env={"MATRIX_PROXY": "socks5://proxy:1080"})
|
||||
assert adapter._proxy_url == "socks5://proxy:1080"
|
||||
|
||||
def test_generic_proxy_fallback(self, monkeypatch):
|
||||
adapter = self._make_adapter(monkeypatch,
|
||||
proxy_env={"HTTPS_PROXY": "http://corp:8080"})
|
||||
assert adapter._proxy_url == "http://corp:8080"
|
||||
|
||||
def test_matrix_proxy_takes_priority(self, monkeypatch):
|
||||
adapter = self._make_adapter(monkeypatch,
|
||||
proxy_env={"MATRIX_PROXY": "socks5://special:1080",
|
||||
"HTTPS_PROXY": "http://generic:8080"})
|
||||
assert adapter._proxy_url == "socks5://special:1080"
|
||||
|
||||
|
||||
class TestCreateMatrixSession:
|
||||
"""Verify _create_matrix_session applies proxy at the session level."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_proxy_returns_trust_env_session(self):
|
||||
with patch.dict("sys.modules", _make_fake_mautrix()):
|
||||
from gateway.platforms.matrix import _create_matrix_session
|
||||
session = _create_matrix_session(None)
|
||||
try:
|
||||
assert session.trust_env is True
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_proxy_sets_default_proxy(self):
|
||||
with patch.dict("sys.modules", _make_fake_mautrix()):
|
||||
from gateway.platforms.matrix import _create_matrix_session
|
||||
session = _create_matrix_session("http://proxy:8080")
|
||||
try:
|
||||
assert str(session._default_proxy) == "http://proxy:8080"
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_socks_proxy_uses_connector(self):
|
||||
fake_connector = MagicMock()
|
||||
with patch.dict("sys.modules", _make_fake_mautrix()):
|
||||
with patch.dict("sys.modules", {
|
||||
"aiohttp_socks": MagicMock(
|
||||
ProxyConnector=MagicMock(
|
||||
from_url=MagicMock(return_value=fake_connector)
|
||||
)
|
||||
),
|
||||
}):
|
||||
from gateway.platforms.matrix import _create_matrix_session
|
||||
session = _create_matrix_session("socks5://proxy:1080")
|
||||
try:
|
||||
assert session.connector is fake_connector
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
60
tests/gateway/test_matrix_exec_approval.py
Normal file
60
tests/gateway/test_matrix_exec_approval.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
class TestMatrixExecApprovalReactions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||
adapter._client = types.SimpleNamespace()
|
||||
adapter.send = AsyncMock(return_value=types.SimpleNamespace(success=True, message_id="$evt1"))
|
||||
adapter._send_reaction = AsyncMock(return_value="$r")
|
||||
|
||||
result = await adapter.send_exec_approval(
|
||||
chat_id="!room:example.org",
|
||||
command="rm -rf /tmp/test",
|
||||
session_key="sess-1",
|
||||
description="dangerous",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
|
||||
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
|
||||
assert adapter._send_reaction.await_count == 2
|
||||
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
|
||||
assert emojis == ["✅", "❎"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_resolves_pending_approval(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt
|
||||
|
||||
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||
# Resolve user_id so _is_self_sender doesn't defensively drop all traffic (#15763).
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._approval_prompts_by_event["$target"] = _MatrixApprovalPrompt(
|
||||
session_key="sess-1", chat_id="!room:example.org", message_id="$target"
|
||||
)
|
||||
adapter._approval_prompt_by_session["sess-1"] = "$target"
|
||||
|
||||
content = {"m.relates_to": {"event_id": "$target", "key": "✅"}}
|
||||
event = types.SimpleNamespace(
|
||||
sender="@liizfq:liizfq.top",
|
||||
event_id="$react1",
|
||||
room_id="!room:example.org",
|
||||
content=content,
|
||||
)
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._on_reaction(event)
|
||||
|
||||
mock_resolve.assert_called_once_with("sess-1", "once")
|
||||
assert "$target" not in adapter._approval_prompts_by_event
|
||||
assert "sess-1" not in adapter._approval_prompt_by_session
|
||||
@@ -159,7 +159,7 @@ class TestStripMention:
|
||||
assert result == "help me"
|
||||
|
||||
def test_localpart_preserved(self):
|
||||
"""Localpart-only text is no longer stripped — avoids false positives in paths."""
|
||||
"""Bare localpart (no @) is preserved — avoids false positives in paths."""
|
||||
result = self.adapter._strip_mention("hermes help me")
|
||||
assert result == "hermes help me"
|
||||
|
||||
@@ -168,11 +168,98 @@ class TestStripMention:
|
||||
result = self.adapter._strip_mention("read /home/hermes/config.yaml")
|
||||
assert result == "read /home/hermes/config.yaml"
|
||||
|
||||
def test_strip_localpart_when_explicit_at_mention(self):
|
||||
result = self.adapter._strip_mention("@hermes help me")
|
||||
assert result == "help me"
|
||||
|
||||
def test_does_not_strip_bare_localpart_word(self):
|
||||
# Regression: plain words like "Hermes Agent" should not be mutated.
|
||||
result = self.adapter._strip_mention("Hermes Agent")
|
||||
assert result == "Hermes Agent"
|
||||
|
||||
def test_strip_returns_empty_for_mention_only(self):
|
||||
result = self.adapter._strip_mention("@hermes:example.org")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound mention payloads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOutboundMentions:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_client.send_message_event = AsyncMock(return_value="$evt1")
|
||||
self.adapter._client = self.mock_client
|
||||
|
||||
@staticmethod
|
||||
def _sent_content(mock_client):
|
||||
call_args = mock_client.send_message_event.call_args
|
||||
return call_args.args[2] if len(call_args.args) > 2 else call_args.kwargs["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_adds_matrix_mentions_and_formatted_body(self):
|
||||
result = await self.adapter.send(
|
||||
"!room1:example.org",
|
||||
"Hello @alice:example.org, please check this.",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert content["formatted_body"] == (
|
||||
'Hello <a href="https://matrix.to/#/@alice:example.org">'
|
||||
"@alice:example.org</a>, please check this."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dedupes_mentions_and_ignores_code_spans(self):
|
||||
await self.adapter.send(
|
||||
"!room1:example.org",
|
||||
"Ping @alice:example.org and @alice:example.org, not `@code:example.org`.",
|
||||
)
|
||||
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert "@code:example.org</a>" not in content["formatted_body"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_preserves_mentions(self):
|
||||
result = await self.adapter.edit_message(
|
||||
"!room1:example.org",
|
||||
"$original",
|
||||
"Updated for @alice:example.org",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert content["m.new_content"]["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert content["m.new_content"]["formatted_body"] == (
|
||||
'Updated for <a href="https://matrix.to/#/@alice:example.org">'
|
||||
"@alice:example.org</a>"
|
||||
)
|
||||
assert content["formatted_body"] == (
|
||||
'* Updated for <a href="https://matrix.to/#/@alice:example.org">'
|
||||
"@alice:example.org</a>"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_simple_notice_adds_mentions(self):
|
||||
result = await self.adapter._send_simple_message(
|
||||
"!room1:example.org",
|
||||
"Heads up @alice:example.org",
|
||||
msgtype="m.notice",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["msgtype"] == "m.notice"
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Require-mention gating in _on_room_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
|
||||
@@ -582,3 +584,47 @@ class TestTruncateMessageUtf16:
|
||||
f"Chunk {i} has unbalanced fences ({fence_count})"
|
||||
)
|
||||
|
||||
|
||||
class TestProxyKwargsForAiohttp:
|
||||
"""Verify proxy_kwargs_for_aiohttp routes all schemes through ProxyConnector."""
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
from gateway.platforms.base import proxy_kwargs_for_aiohttp
|
||||
|
||||
sess_kw, req_kw = proxy_kwargs_for_aiohttp(None)
|
||||
assert sess_kw == {}
|
||||
assert req_kw == {}
|
||||
|
||||
def test_http_proxy_uses_connector_when_aiohttp_socks_available(self):
|
||||
pytest.importorskip("aiohttp_socks")
|
||||
from unittest.mock import MagicMock
|
||||
from gateway.platforms.base import proxy_kwargs_for_aiohttp
|
||||
|
||||
sentinel = MagicMock(name="ProxyConnector")
|
||||
with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel):
|
||||
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
|
||||
assert sess_kw.get("connector") is sentinel, (
|
||||
"HTTP proxy must use ProxyConnector so libraries that don't "
|
||||
"forward per-request proxy= kwargs still route through the proxy"
|
||||
)
|
||||
assert req_kw == {}
|
||||
|
||||
def test_socks_proxy_uses_connector(self):
|
||||
pytest.importorskip("aiohttp_socks")
|
||||
from unittest.mock import MagicMock
|
||||
from gateway.platforms.base import proxy_kwargs_for_aiohttp
|
||||
|
||||
sentinel = MagicMock(name="ProxyConnector")
|
||||
with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel):
|
||||
sess_kw, req_kw = proxy_kwargs_for_aiohttp("socks5://proxy:1080")
|
||||
assert sess_kw.get("connector") is sentinel
|
||||
assert req_kw == {}
|
||||
|
||||
def test_http_proxy_falls_back_without_aiohttp_socks(self):
|
||||
from gateway.platforms.base import proxy_kwargs_for_aiohttp
|
||||
|
||||
with patch.dict("sys.modules", {"aiohttp_socks": None}):
|
||||
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
|
||||
assert sess_kw == {}
|
||||
assert req_kw == {"proxy": "http://proxy:8080"}
|
||||
|
||||
|
||||
168
tests/hermes_cli/test_optional_plugins.py
Normal file
168
tests/hermes_cli/test_optional_plugins.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Tests for optional-plugins (official) install path in plugins_cmd."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_official_plugin_dir(tmp_path: Path, category: str, name: str) -> Path:
|
||||
"""Create a minimal optional-plugin directory structure."""
|
||||
plugin_dir = tmp_path / "optional-plugins" / category / name
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
f"name: {name}\nversion: 1.0.0\ndescription: Test plugin\n"
|
||||
)
|
||||
(plugin_dir / "__init__.py").write_text("def register(ctx): pass\n")
|
||||
return plugin_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_official_plugin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveOfficialPlugin:
|
||||
def test_returns_none_for_git_url(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _resolve_official_plugin
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _resolve_official_plugin("https://github.com/owner/repo.git")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_owner_repo(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _resolve_official_plugin
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _resolve_official_plugin("owner/repo")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_missing_plugin(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _resolve_official_plugin
|
||||
(tmp_path / "optional-plugins").mkdir()
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _resolve_official_plugin("official/observability/nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_returns_path_for_existing_plugin(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _resolve_official_plugin
|
||||
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _resolve_official_plugin("official/observability/langfuse")
|
||||
assert result == plugin_dir
|
||||
|
||||
def test_accepts_without_official_prefix(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _resolve_official_plugin
|
||||
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _resolve_official_plugin("observability/langfuse")
|
||||
assert result == plugin_dir
|
||||
|
||||
def test_traversal_blocked(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _resolve_official_plugin
|
||||
(tmp_path / "optional-plugins").mkdir()
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _resolve_official_plugin("official/../../etc/passwd")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _list_official_plugins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListOfficialPlugins:
|
||||
def test_empty_when_no_optional_plugins_dir(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _list_official_plugins
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "nonexistent"):
|
||||
result = _list_official_plugins()
|
||||
assert result == []
|
||||
|
||||
def test_lists_plugins_with_descriptions(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _list_official_plugins
|
||||
_make_official_plugin_dir(tmp_path, "observability", "langfuse")
|
||||
_make_official_plugin_dir(tmp_path, "observability", "other-plugin")
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _list_official_plugins()
|
||||
identifiers = [r[0] for r in result]
|
||||
assert "official/observability/langfuse" in identifiers
|
||||
assert "official/observability/other-plugin" in identifiers
|
||||
|
||||
def test_descriptions_parsed_from_yaml(self, tmp_path):
|
||||
from hermes_cli.plugins_cmd import _list_official_plugins
|
||||
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
|
||||
result = _list_official_plugins()
|
||||
assert any(desc == "Test plugin" for _, desc in result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_install — official path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCmdInstallOfficial:
|
||||
def test_install_official_plugin_copies_files(self, tmp_path, monkeypatch):
|
||||
from hermes_cli.plugins_cmd import cmd_install
|
||||
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
|
||||
user_plugins = tmp_path / "user-plugins"
|
||||
user_plugins.mkdir()
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins_cmd._optional_plugins_dir",
|
||||
lambda: tmp_path / "optional-plugins")
|
||||
monkeypatch.setattr("hermes_cli.plugins_cmd._plugins_dir",
|
||||
lambda: user_plugins)
|
||||
# Non-interactive: don't prompt
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||
|
||||
cmd_install("official/observability/langfuse", enable=False)
|
||||
|
||||
installed = user_plugins / "langfuse"
|
||||
assert installed.is_dir()
|
||||
assert (installed / "plugin.yaml").exists()
|
||||
assert (installed / "__init__.py").exists()
|
||||
|
||||
def test_install_official_plugin_respects_force(self, tmp_path, monkeypatch):
|
||||
from hermes_cli.plugins_cmd import cmd_install
|
||||
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
|
||||
user_plugins = tmp_path / "user-plugins"
|
||||
user_plugins.mkdir()
|
||||
# Pre-create to simulate already-installed
|
||||
already = user_plugins / "langfuse"
|
||||
already.mkdir()
|
||||
(already / "old.txt").write_text("old")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins_cmd._optional_plugins_dir",
|
||||
lambda: tmp_path / "optional-plugins")
|
||||
monkeypatch.setattr("hermes_cli.plugins_cmd._plugins_dir",
|
||||
lambda: user_plugins)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||
|
||||
cmd_install("official/observability/langfuse", force=True, enable=False)
|
||||
|
||||
# Old file should be gone, new files present
|
||||
assert not (already / "old.txt").exists()
|
||||
assert (already / "plugin.yaml").exists()
|
||||
|
||||
def test_install_official_plugin_exits_without_force_when_exists(self, tmp_path, monkeypatch):
|
||||
from hermes_cli.plugins_cmd import cmd_install
|
||||
_make_official_plugin_dir(tmp_path, "observability", "langfuse")
|
||||
user_plugins = tmp_path / "user-plugins"
|
||||
user_plugins.mkdir()
|
||||
(user_plugins / "langfuse").mkdir()
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins_cmd._optional_plugins_dir",
|
||||
lambda: tmp_path / "optional-plugins")
|
||||
monkeypatch.setattr("hermes_cli.plugins_cmd._plugins_dir",
|
||||
lambda: user_plugins)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cmd_install("official/observability/langfuse", enable=False)
|
||||
|
||||
def test_git_url_not_mistaken_for_official(self, tmp_path, monkeypatch):
|
||||
"""A git URL must not trigger the official install path."""
|
||||
from hermes_cli.plugins_cmd import _resolve_official_plugin
|
||||
with patch("hermes_cli.plugins_cmd._optional_plugins_dir",
|
||||
return_value=tmp_path / "optional-plugins"):
|
||||
assert _resolve_official_plugin("https://github.com/owner/repo") is None
|
||||
assert _resolve_official_plugin("owner/repo") is None
|
||||
@@ -72,8 +72,12 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path):
|
||||
assert "ENV_VAR=false" in result.stdout
|
||||
|
||||
|
||||
def test_redact_secrets_default_true_when_unset(tmp_path):
|
||||
"""Without the config key, redaction stays on by default."""
|
||||
def test_redact_secrets_default_false_when_unset(tmp_path):
|
||||
"""Without the config key, redaction stays OFF by default.
|
||||
|
||||
Secret redaction is opt-in — users who want it must set
|
||||
`security.redact_secrets: true` explicitly (or HERMES_REDACT_SECRETS=true).
|
||||
"""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("{}\n") # empty config
|
||||
@@ -103,7 +107,53 @@ def test_redact_secrets_default_true_when_unset(tmp_path):
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"probe failed: {result.stderr}"
|
||||
assert "REDACT_ENABLED=True" in result.stdout
|
||||
assert "REDACT_ENABLED=False" in result.stdout
|
||||
|
||||
|
||||
def test_redact_secrets_true_in_config_yaml_is_honored(tmp_path):
|
||||
"""Setting `security.redact_secrets: true` in config.yaml must enable
|
||||
redaction — even though it's set in YAML, not as an env var."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
security:
|
||||
redact_secrets: true
|
||||
"""
|
||||
)
|
||||
)
|
||||
(hermes_home / ".env").write_text("")
|
||||
|
||||
probe = textwrap.dedent(
|
||||
"""\
|
||||
import sys, os
|
||||
os.environ.pop("HERMES_REDACT_SECRETS", None)
|
||||
sys.path.insert(0, %r)
|
||||
import hermes_cli.main
|
||||
import agent.redact
|
||||
print(f"REDACT_ENABLED={agent.redact._REDACT_ENABLED}")
|
||||
print(f"ENV_VAR={os.environ.get('HERMES_REDACT_SECRETS', '<unset>')}")
|
||||
"""
|
||||
) % str(REPO_ROOT)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["HERMES_HOME"] = str(hermes_home)
|
||||
env.pop("HERMES_REDACT_SECRETS", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", probe],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"probe failed: {result.stderr}"
|
||||
assert "REDACT_ENABLED=True" in result.stdout, (
|
||||
f"Config toggle not honored.\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
assert "ENV_VAR=true" in result.stdout
|
||||
|
||||
|
||||
def test_dotenv_redact_secrets_beats_config_yaml(tmp_path):
|
||||
|
||||
@@ -1373,6 +1373,144 @@ class TestSchemaInit:
|
||||
|
||||
migrated_db.close()
|
||||
|
||||
def test_reconciliation_adds_missing_columns(self, tmp_path):
|
||||
"""Columns present in SCHEMA_SQL but missing from the live table
|
||||
are added by _reconcile_columns regardless of schema_version.
|
||||
|
||||
Regression test: commit a7d78d3b inserted a new v7 migration
|
||||
(reasoning_content) and renumbered the old v7 (api_call_count)
|
||||
to v8. Users already at the old v7 had schema_version >= 7,
|
||||
so the new v7 block was skipped and reasoning_content was never
|
||||
created — causing 'no such column' on /continue.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
db_path = tmp_path / "gap_test.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
# Simulate the old v7 state: api_call_count exists, reasoning_content does NOT
|
||||
conn.executescript("""
|
||||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||||
INSERT INTO schema_version (version) VALUES (7);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
model_config TEXT,
|
||||
system_prompt TEXT,
|
||||
parent_session_id TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
tool_call_count INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT
|
||||
);
|
||||
""")
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
||||
("s1", "cli", 1000.0),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
("s1", "assistant", "hello", 1001.0),
|
||||
)
|
||||
conn.commit()
|
||||
# Verify reasoning_content is absent
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||||
assert "reasoning_content" not in cols
|
||||
conn.close()
|
||||
|
||||
# Open with SessionDB — reconciliation should add the missing column
|
||||
migrated_db = SessionDB(db_path=db_path)
|
||||
|
||||
msg_cols = {
|
||||
r[1]
|
||||
for r in migrated_db._conn.execute("PRAGMA table_info(messages)").fetchall()
|
||||
}
|
||||
assert "reasoning_content" in msg_cols
|
||||
|
||||
# The query that used to crash must now work
|
||||
cursor = migrated_db._conn.execute(
|
||||
"SELECT role, content, reasoning, reasoning_content, "
|
||||
"reasoning_details, codex_reasoning_items "
|
||||
"FROM messages WHERE session_id = ?",
|
||||
("s1",),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "assistant"
|
||||
assert row[3] is None # reasoning_content NULL for old rows
|
||||
|
||||
migrated_db.close()
|
||||
|
||||
def test_reconciliation_is_idempotent(self, tmp_path):
|
||||
"""Opening the same database twice doesn't error or duplicate columns."""
|
||||
db_path = tmp_path / "idempotent.db"
|
||||
db1 = SessionDB(db_path=db_path)
|
||||
cols1 = {r[1] for r in db1._conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||||
db1.close()
|
||||
|
||||
db2 = SessionDB(db_path=db_path)
|
||||
cols2 = {r[1] for r in db2._conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||||
db2.close()
|
||||
|
||||
assert cols1 == cols2
|
||||
|
||||
def test_schema_sql_is_source_of_truth(self, db):
|
||||
"""Every column in SCHEMA_SQL exists in the live database.
|
||||
|
||||
This is the architectural invariant: SCHEMA_SQL declares the
|
||||
desired schema, _reconcile_columns ensures it matches reality.
|
||||
"""
|
||||
from hermes_state import SCHEMA_SQL
|
||||
|
||||
expected = SessionDB._parse_schema_columns(SCHEMA_SQL)
|
||||
for table_name, declared_cols in expected.items():
|
||||
live_cols = {
|
||||
r[1]
|
||||
for r in db._conn.execute(
|
||||
f'PRAGMA table_info("{table_name}")'
|
||||
).fetchall()
|
||||
}
|
||||
for col_name in declared_cols:
|
||||
assert col_name in live_cols, (
|
||||
f"Column {col_name} declared in SCHEMA_SQL for {table_name} "
|
||||
f"but missing from live DB. Live columns: {live_cols}"
|
||||
)
|
||||
|
||||
|
||||
class TestTitleUniqueness:
|
||||
"""Tests for unique title enforcement and title-based lookups."""
|
||||
|
||||
@@ -568,6 +568,163 @@ class TestDelegateObservability(unittest.TestCase):
|
||||
self.assertEqual(result["results"][0]["exit_reason"], "max_iterations")
|
||||
|
||||
|
||||
class TestSubagentCostRollup(unittest.TestCase):
|
||||
"""Port of Kilo-Org/kilocode#9448 — parent's session_estimated_cost_usd
|
||||
must include subagent spend, not just the parent's own API calls."""
|
||||
|
||||
def _make_parent_with_cost_counters(self, depth=0, starting_cost=0.0):
|
||||
parent = _make_mock_parent(depth=depth)
|
||||
# The fields AIAgent exposes and the footer reads from. Set real
|
||||
# floats/strings so the rollup can add to them rather than tripping
|
||||
# on MagicMock auto-attrs.
|
||||
parent.session_estimated_cost_usd = starting_cost
|
||||
parent.session_cost_status = "unknown"
|
||||
parent.session_cost_source = "none"
|
||||
return parent
|
||||
|
||||
def test_single_child_cost_folded_into_parent(self):
|
||||
parent = self._make_parent_with_cost_counters(starting_cost=0.10)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.model = "claude-sonnet-4-6"
|
||||
mock_child.session_prompt_tokens = 1000
|
||||
mock_child.session_completion_tokens = 200
|
||||
mock_child.session_estimated_cost_usd = 0.42
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done",
|
||||
"completed": True,
|
||||
"interrupted": False,
|
||||
"api_calls": 2,
|
||||
"messages": [],
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
result = json.loads(delegate_task(goal="do stuff", parent_agent=parent))
|
||||
|
||||
# Parent footer must reflect parent_cost + child_cost.
|
||||
self.assertAlmostEqual(parent.session_estimated_cost_usd, 0.52, places=6)
|
||||
# Rollup must strip the internal field before serialising to the model.
|
||||
self.assertNotIn("_child_cost_usd", result["results"][0])
|
||||
self.assertNotIn("_child_role", result["results"][0])
|
||||
|
||||
def test_batch_children_costs_sum_into_parent(self):
|
||||
parent = self._make_parent_with_cost_counters(starting_cost=0.00)
|
||||
|
||||
with patch("tools.delegate_tool._run_single_child") as mock_run:
|
||||
mock_run.side_effect = [
|
||||
{
|
||||
"task_index": 0,
|
||||
"status": "completed",
|
||||
"summary": "A",
|
||||
"api_calls": 2,
|
||||
"duration_seconds": 1.0,
|
||||
"_child_role": "leaf",
|
||||
"_child_cost_usd": 0.15,
|
||||
},
|
||||
{
|
||||
"task_index": 1,
|
||||
"status": "completed",
|
||||
"summary": "B",
|
||||
"api_calls": 2,
|
||||
"duration_seconds": 1.0,
|
||||
"_child_role": "leaf",
|
||||
"_child_cost_usd": 0.27,
|
||||
},
|
||||
{
|
||||
"task_index": 2,
|
||||
"status": "failed",
|
||||
"summary": "",
|
||||
"error": "boom",
|
||||
"api_calls": 0,
|
||||
"duration_seconds": 0.1,
|
||||
"_child_role": "leaf",
|
||||
"_child_cost_usd": 0.03,
|
||||
},
|
||||
]
|
||||
result = json.loads(
|
||||
delegate_task(
|
||||
tasks=[{"goal": "A"}, {"goal": "B"}, {"goal": "C"}],
|
||||
parent_agent=parent,
|
||||
)
|
||||
)
|
||||
|
||||
# 0.15 + 0.27 + 0.03 even though one child failed — the API calls it
|
||||
# made before failing still cost money.
|
||||
self.assertAlmostEqual(parent.session_estimated_cost_usd, 0.45, places=6)
|
||||
# cost_source promoted from "none" since the parent had no direct spend.
|
||||
self.assertEqual(parent.session_cost_source, "subagent")
|
||||
self.assertEqual(parent.session_cost_status, "estimated")
|
||||
# All internal fields stripped from results.
|
||||
for entry in result["results"]:
|
||||
self.assertNotIn("_child_cost_usd", entry)
|
||||
self.assertNotIn("_child_role", entry)
|
||||
|
||||
def test_zero_cost_children_leave_parent_source_untouched(self):
|
||||
"""If every child reports 0 cost (e.g. free local model), we should
|
||||
not invent a fake 'subagent' source — the parent's 'none' stays."""
|
||||
parent = self._make_parent_with_cost_counters(starting_cost=0.00)
|
||||
|
||||
with patch("tools.delegate_tool._run_single_child") as mock_run:
|
||||
mock_run.return_value = {
|
||||
"task_index": 0,
|
||||
"status": "completed",
|
||||
"summary": "done",
|
||||
"api_calls": 1,
|
||||
"duration_seconds": 0.5,
|
||||
"_child_role": "leaf",
|
||||
"_child_cost_usd": 0.0,
|
||||
}
|
||||
delegate_task(goal="free local run", parent_agent=parent)
|
||||
|
||||
self.assertEqual(parent.session_estimated_cost_usd, 0.0)
|
||||
self.assertEqual(parent.session_cost_source, "none")
|
||||
|
||||
def test_parent_with_real_source_not_overwritten(self):
|
||||
"""If the parent already has its own cost billed (cost_source != 'none'),
|
||||
adding subagent cost must not clobber the existing source label."""
|
||||
parent = self._make_parent_with_cost_counters(starting_cost=0.20)
|
||||
parent.session_cost_status = "exact"
|
||||
parent.session_cost_source = "openrouter"
|
||||
|
||||
with patch("tools.delegate_tool._run_single_child") as mock_run:
|
||||
mock_run.return_value = {
|
||||
"task_index": 0,
|
||||
"status": "completed",
|
||||
"summary": "done",
|
||||
"api_calls": 1,
|
||||
"duration_seconds": 0.5,
|
||||
"_child_role": "leaf",
|
||||
"_child_cost_usd": 0.30,
|
||||
}
|
||||
delegate_task(goal="billed run", parent_agent=parent)
|
||||
|
||||
self.assertAlmostEqual(parent.session_estimated_cost_usd, 0.50, places=6)
|
||||
# Real source label preserved.
|
||||
self.assertEqual(parent.session_cost_source, "openrouter")
|
||||
self.assertEqual(parent.session_cost_status, "exact")
|
||||
|
||||
def test_rollup_tolerates_missing_cost_fields(self):
|
||||
"""Older fixtures / fabricated error entries may not carry
|
||||
_child_cost_usd. Rollup must degrade to zero-add silently."""
|
||||
parent = self._make_parent_with_cost_counters(starting_cost=0.10)
|
||||
|
||||
with patch("tools.delegate_tool._run_single_child") as mock_run:
|
||||
mock_run.return_value = {
|
||||
"task_index": 0,
|
||||
"status": "completed",
|
||||
"summary": "done",
|
||||
"api_calls": 1,
|
||||
"duration_seconds": 0.5,
|
||||
# no _child_role, no _child_cost_usd
|
||||
}
|
||||
result = json.loads(delegate_task(goal="legacy", parent_agent=parent))
|
||||
|
||||
# Parent cost unchanged.
|
||||
self.assertEqual(parent.session_estimated_cost_usd, 0.10)
|
||||
self.assertEqual(len(result["results"]), 1)
|
||||
|
||||
|
||||
class TestBlockedTools(unittest.TestCase):
|
||||
def test_blocked_tools_constant(self):
|
||||
for tool in ["delegate_task", "clarify", "memory", "send_message", "execute_code"]:
|
||||
|
||||
@@ -1616,6 +1616,19 @@ def _run_single_child(
|
||||
# parent thread can fire subagent_stop with the correct role.
|
||||
# Stripped before the dict is serialised back to the model.
|
||||
"_child_role": getattr(child, "_delegate_role", None),
|
||||
# Captured before child.close() so the parent aggregator can fold
|
||||
# the child's total spend into the parent's session cost. Port of
|
||||
# Kilo-Org/kilocode#9448 — previously the footer only reflected the
|
||||
# parent's direct API calls and under-counted subagent-heavy runs.
|
||||
# Stripped before the dict is serialised back to the model.
|
||||
"_child_cost_usd": (
|
||||
float(getattr(child, "session_estimated_cost_usd", 0.0) or 0.0)
|
||||
if isinstance(
|
||||
getattr(child, "session_estimated_cost_usd", 0.0),
|
||||
(int, float),
|
||||
)
|
||||
else 0.0
|
||||
),
|
||||
}
|
||||
if status == "failed":
|
||||
entry["error"] = result.get("error", "Subagent did not produce a response.")
|
||||
@@ -2112,8 +2125,20 @@ def delegate_task(
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
except Exception:
|
||||
_invoke_hook = None
|
||||
# Aggregate child spend here so the parent's footer/UI reflect the true
|
||||
# cost of a subagent-heavy turn. Port of Kilo-Org/kilocode#9448. Each
|
||||
# child's cost was captured in _run_single_child before its AIAgent was
|
||||
# closed; we fold them into the parent in one pass alongside the
|
||||
# subagent_stop hook loop so we don't walk `results` twice.
|
||||
_children_cost_total = 0.0
|
||||
for entry in results:
|
||||
child_role = entry.pop("_child_role", None)
|
||||
child_cost = entry.pop("_child_cost_usd", 0.0)
|
||||
try:
|
||||
if child_cost:
|
||||
_children_cost_total += float(child_cost)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if _invoke_hook is None:
|
||||
continue
|
||||
try:
|
||||
@@ -2128,6 +2153,28 @@ def delegate_task(
|
||||
except Exception:
|
||||
logger.debug("subagent_stop hook invocation failed", exc_info=True)
|
||||
|
||||
# Fold the aggregated child cost into the parent's session total. This is
|
||||
# additive — each delegate_task call contributes its own children — so
|
||||
# nested orchestrator→worker trees roll up naturally: each layer's own
|
||||
# delegate_task() folds its direct children in, and when the orchestrator
|
||||
# itself finishes, its parent folds the orchestrator's now-inflated total
|
||||
# on top. Degrades silently if the parent lacks the counter (older test
|
||||
# fixtures, etc.).
|
||||
if _children_cost_total > 0.0:
|
||||
try:
|
||||
current = float(getattr(parent_agent, "session_estimated_cost_usd", 0.0) or 0.0)
|
||||
parent_agent.session_estimated_cost_usd = current + _children_cost_total
|
||||
# Upgrade the cost_source so the UI doesn't label a partially-real
|
||||
# total as "none" when the parent itself hadn't billed any calls
|
||||
# yet (rare but possible when the parent's only action this turn
|
||||
# was delegate_task).
|
||||
if getattr(parent_agent, "session_cost_source", "none") in (None, "", "none"):
|
||||
parent_agent.session_cost_source = "subagent"
|
||||
if getattr(parent_agent, "session_cost_status", "unknown") in (None, "", "unknown"):
|
||||
parent_agent.session_cost_status = "estimated"
|
||||
except Exception:
|
||||
logger.debug("Subagent cost rollup failed", exc_info=True)
|
||||
|
||||
total_duration = round(time.monotonic() - overall_start, 2)
|
||||
|
||||
return json.dumps(
|
||||
|
||||
@@ -237,6 +237,9 @@ export const en: Translations = {
|
||||
exportConfig: "Export config as JSON",
|
||||
importConfig: "Import config from JSON",
|
||||
resetDefaults: "Reset to defaults",
|
||||
resetScopeTooltip: "Reset {scope} to defaults",
|
||||
confirmResetScope: "Reset all {scope} settings to their defaults? This only updates the form — changes aren't written to config.yaml until you press Save.",
|
||||
resetScopeToast: "{scope} reset to defaults — review and Save to persist",
|
||||
rawYaml: "Raw YAML Configuration",
|
||||
searchResults: "Search Results",
|
||||
fields: "field{s}",
|
||||
|
||||
@@ -242,6 +242,9 @@ export interface Translations {
|
||||
exportConfig: string;
|
||||
importConfig: string;
|
||||
resetDefaults: string;
|
||||
resetScopeTooltip: string;
|
||||
confirmResetScope: string;
|
||||
resetScopeToast: string;
|
||||
rawYaml: string;
|
||||
searchResults: string;
|
||||
fields: string;
|
||||
|
||||
@@ -234,6 +234,9 @@ export const zh: Translations = {
|
||||
exportConfig: "导出配置为 JSON",
|
||||
importConfig: "从 JSON 导入配置",
|
||||
resetDefaults: "恢复默认值",
|
||||
resetScopeTooltip: "将{scope}恢复为默认值",
|
||||
confirmResetScope: "确定要将{scope}的所有设置恢复为默认值吗?此操作仅更新表单,在按下「保存」按钮前不会写入 config.yaml。",
|
||||
resetScopeToast: "{scope}已恢复为默认值 — 请检查并保存以生效",
|
||||
rawYaml: "原始 YAML 配置",
|
||||
searchResults: "搜索结果",
|
||||
fields: "个字段",
|
||||
|
||||
@@ -228,7 +228,26 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (defaults) setConfig(structuredClone(defaults));
|
||||
if (!defaults || !config) return;
|
||||
// Scope the reset to what the user is currently looking at:
|
||||
// - search mode → the matched fields
|
||||
// - form mode → the active category's fields
|
||||
// Resetting the whole config here was a footgun (issue reported by @ykmfb001):
|
||||
// the button sits next to the category tabs and users reasonably assumed
|
||||
// "reset this tab", not "wipe my entire config.yaml".
|
||||
const scopedFields = isSearching ? searchMatchedFields : activeFields;
|
||||
if (scopedFields.length === 0) return;
|
||||
const scopeLabel = isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory);
|
||||
const message = t.config.confirmResetScope.replace("{scope}", scopeLabel);
|
||||
if (!window.confirm(message)) return;
|
||||
let next: Record<string, unknown> = config;
|
||||
for (const [key] of scopedFields) {
|
||||
next = setNestedValue(next, key, getNestedValue(defaults, key));
|
||||
}
|
||||
setConfig(next);
|
||||
showToast(t.config.resetScopeToast.replace("{scope}", scopeLabel), "success");
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
@@ -333,9 +352,17 @@ export default function ConfigPage() {
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} title={t.config.resetDefaults} aria-label={t.config.resetDefaults}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{!yamlMode && (() => {
|
||||
const resetScopeLabel = isSearching
|
||||
? t.config.searchResults
|
||||
: prettyCategoryName(activeCategory);
|
||||
const resetTitle = t.config.resetScopeTooltip.replace("{scope}", resetScopeLabel);
|
||||
return (
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} title={resetTitle} aria-label={resetTitle}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
|
||||
@@ -1313,7 +1313,7 @@ Pre-execution security scanning and secret redaction:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
redact_secrets: true # Redact API key patterns in tool output and logs
|
||||
redact_secrets: false # Redact API key patterns in tool output and logs (off by default)
|
||||
tirith_enabled: true # Enable Tirith security scanning for terminal commands
|
||||
tirith_path: "tirith" # Path to tirith binary (default: "tirith" in $PATH)
|
||||
tirith_timeout: 5 # Seconds to wait for tirith scan before timing out
|
||||
@@ -1324,7 +1324,7 @@ security:
|
||||
shared_files: []
|
||||
```
|
||||
|
||||
- `redact_secrets` — automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs.
|
||||
- `redact_secrets` — when `true`, automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs. **Off by default** — enable if you commonly work with real credentials in tool output and want a safety net. Set to `true` explicitly to turn on.
|
||||
- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/StackGuardian/tirith) before execution to detect potentially dangerous operations.
|
||||
- `tirith_path` — path to the tirith binary. Set this if tirith is installed in a non-standard location.
|
||||
- `tirith_timeout` — maximum seconds to wait for a tirith scan. Commands proceed if the scan times out.
|
||||
|
||||
Reference in New Issue
Block a user