Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddd2542ba5 |
@@ -202,33 +202,19 @@ 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), the first two are GA on Claude 4.6+ — the
|
||||
# As of Opus 4.7 (2026-04-16), both of these 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.
|
||||
#
|
||||
# ``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.
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models.
|
||||
_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).
|
||||
@@ -371,14 +357,9 @@ 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):
|
||||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||||
return [b for b in _COMMON_BETAS if b not in _stripped]
|
||||
return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
@@ -475,13 +456,6 @@ 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:
|
||||
@@ -499,7 +473,6 @@ 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)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -340,8 +340,6 @@ class ContextCompressor(ContextEngine):
|
||||
self._last_summary_error = None
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
|
||||
@@ -450,12 +448,6 @@ class ContextCompressor(ContextEngine):
|
||||
# (gateway hygiene, /compress) can surface a visible warning.
|
||||
self._last_summary_dropped_count: int = 0
|
||||
self._last_summary_fallback_used: bool = False
|
||||
# When a user-configured summary model fails and we recover by
|
||||
# retrying on the main model, record the failure so gateway /
|
||||
# CLI callers can still warn the user even though compression
|
||||
# succeeded. Silent recovery would hide the broken config.
|
||||
self._last_aux_model_failure_error: Optional[str] = None
|
||||
self._last_aux_model_failure_model: Optional[str] = None
|
||||
|
||||
def update_from_response(self, usage: Dict[str, Any]):
|
||||
"""Update tracked token usage from API response."""
|
||||
@@ -915,50 +907,10 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
"Falling back to main model '%s' for compression.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure so callers can warn the user
|
||||
# even if the retry-on-main succeeds — a misconfigured aux
|
||||
# model is something the user needs to fix.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) # retry immediately
|
||||
|
||||
# Unknown-error best-effort retry on main model. Losing N turns of
|
||||
# context is almost always worse than one extra summary attempt, so
|
||||
# if we haven't already fallen back and the summary model differs
|
||||
# from the main model, try once more on main before entering
|
||||
# cooldown. Errors that DID match _is_model_not_found above are
|
||||
# already handled by the fast-path retry; this branch catches
|
||||
# everything else (400s, provider-specific "no route" strings,
|
||||
# aggregator rejections, etc.) where auto-retry is still safer
|
||||
# than dropping the turns.
|
||||
if (
|
||||
self.summary_model
|
||||
and self.summary_model != self.model
|
||||
and not getattr(self, "_summary_model_fallen_back", False)
|
||||
):
|
||||
self._summary_model_fallen_back = True
|
||||
logging.warning(
|
||||
"Summary model '%s' failed (%s). "
|
||||
"Retrying on main model '%s' before giving up.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure (see 404 branch above) — user
|
||||
# should know their configured model is broken even if main
|
||||
# recovers the call.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
|
||||
# Transient errors (timeout, rate limit, network) — shorter cooldown
|
||||
_transient_cooldown = 60
|
||||
self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown
|
||||
@@ -1256,8 +1208,6 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_summary_error = None
|
||||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
n_messages = len(messages)
|
||||
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
|
||||
_min_for_compress = self.protect_first_n + 3 + 1
|
||||
|
||||
+3
-7
@@ -56,12 +56,8 @@ _SENSITIVE_BODY_KEYS = frozenset({
|
||||
})
|
||||
|
||||
# Snapshot at import time so runtime env mutations (e.g. LLM-generated
|
||||
# `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")
|
||||
# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction mid-session.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off")
|
||||
|
||||
# Known API key prefixes -- match the prefix + contiguous token chars
|
||||
_PREFIX_PATTERNS = [
|
||||
@@ -261,7 +257,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 by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Disabled when security.redact_secrets is false in config.yaml.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
+11
-20
@@ -307,14 +307,9 @@ 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:
|
||||
- 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.
|
||||
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
|
||||
- HTTP → ``({}, {"proxy": url})``
|
||||
- None → ``({}, {})``
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -325,20 +320,20 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""
|
||||
if not proxy_url:
|
||||
return {}, {}
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
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:
|
||||
@@ -2432,15 +2427,11 @@ 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=send_metadata,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
_record_delivery(result)
|
||||
|
||||
|
||||
+42
-438
@@ -11,7 +11,6 @@ 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
|
||||
@@ -19,7 +18,6 @@ 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)
|
||||
"""
|
||||
@@ -32,8 +30,6 @@ 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
|
||||
@@ -99,25 +95,11 @@ 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
|
||||
@@ -132,85 +114,11 @@ _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."""
|
||||
@@ -352,9 +260,6 @@ 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")
|
||||
@@ -365,11 +270,6 @@ 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(
|
||||
@@ -381,18 +281,6 @@ 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:
|
||||
@@ -438,7 +326,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc, exc_info=True)
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -454,7 +342,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.error(
|
||||
"Matrix: cannot verify device keys on server: %s — refusing E2EE",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -469,7 +356,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
await olm.share_keys()
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc, exc_info=True)
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc)
|
||||
return False
|
||||
return await self._reverify_keys_after_upload(client, local_ed25519)
|
||||
|
||||
@@ -509,7 +396,6 @@ 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)
|
||||
@@ -534,11 +420,9 @@ 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.
|
||||
@@ -581,7 +465,6 @@ 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
|
||||
@@ -724,44 +607,6 @@ 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(
|
||||
@@ -819,7 +664,6 @@ 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",
|
||||
@@ -879,32 +723,21 @@ 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 i, chunk in enumerate(chunks):
|
||||
msg_content = self._build_text_message_content(chunk)
|
||||
for chunk in chunks:
|
||||
msg_content: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": 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)
|
||||
# Convert markdown to HTML for rich rendering.
|
||||
html = self._markdown_to_html(chunk)
|
||||
if html and html != chunk:
|
||||
msg_content["format"] = "org.matrix.custom.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]
|
||||
}
|
||||
msg_content["formatted_body"] = html
|
||||
|
||||
# Reply-to support.
|
||||
if reply_to:
|
||||
@@ -1011,21 +844,25 @@ 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": new_content,
|
||||
"m.new_content": {
|
||||
"msgtype": "m.text",
|
||||
"body": formatted,
|
||||
},
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
},
|
||||
}
|
||||
if "m.mentions" in new_content:
|
||||
msg_content["m.mentions"] = new_content["m.mentions"]
|
||||
if "formatted_body" in 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
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = f'* {new_content["formatted_body"]}'
|
||||
msg_content["m.relates_to"] = {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
}
|
||||
msg_content["formatted_body"] = f"* {html}"
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -1058,12 +895,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Try aiohttp first (always available), fall back to httpx
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
|
||||
async with _aiohttp.ClientSession(**_sess_kw) as http:
|
||||
|
||||
async with _aiohttp.ClientSession(trust_env=True) as http:
|
||||
async with http.get(
|
||||
image_url,
|
||||
timeout=_aiohttp.ClientTimeout(total=30),
|
||||
**_req_kw,
|
||||
image_url, timeout=_aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.read()
|
||||
@@ -1073,10 +908,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except ImportError:
|
||||
import httpx
|
||||
_httpx_kw: dict = {}
|
||||
if self._proxy_url:
|
||||
_httpx_kw["proxy"] = self._proxy_url
|
||||
async with httpx.AsyncClient(**_httpx_kw) as http:
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
resp = await http.get(image_url, follow_redirects=True, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
@@ -1151,56 +984,6 @@ 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.
|
||||
@@ -1332,15 +1115,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
next_batch = await client.sync_store.get_next_batch()
|
||||
while not self._closing:
|
||||
try:
|
||||
# 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,
|
||||
sync_data = await client.sync(
|
||||
since=next_batch,
|
||||
timeout=30000,
|
||||
)
|
||||
|
||||
# nio returns SyncError objects (not exceptions) for auth
|
||||
@@ -1376,7 +1153,6 @@ 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
|
||||
@@ -1463,15 +1239,6 @@ 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).
|
||||
@@ -1583,12 +1350,6 @@ 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.
|
||||
@@ -1601,7 +1362,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
body = self._strip_mention(body)
|
||||
|
||||
# Auto-thread.
|
||||
if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)):
|
||||
if not is_dm and not thread_id and self._auto_thread:
|
||||
thread_id = event_id
|
||||
self._threads.mark(thread_id)
|
||||
|
||||
@@ -1843,9 +1604,6 @@ 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]
|
||||
@@ -1875,35 +1633,13 @@ 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)
|
||||
@@ -2018,51 +1754,6 @@ 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)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2288,7 +1979,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if not self._client or not text:
|
||||
return SendResult(success=False, error="No client or empty text")
|
||||
|
||||
msg_content = self._build_text_message_content(text, msgtype=msgtype)
|
||||
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
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -2351,77 +2046,6 @@ 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,
|
||||
@@ -2456,33 +2080,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
def _strip_mention(self, body: str) -> str:
|
||||
"""Remove explicit bot mentions from message body.
|
||||
"""Strip the bot's full MXID (``@user:server``) from *body*.
|
||||
|
||||
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".
|
||||
The bare localpart is intentionally *not* stripped — it would
|
||||
mangle file paths like ``/home/hermes/media/file.png``.
|
||||
"""
|
||||
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:
|
||||
|
||||
+1
-37
@@ -4828,30 +4828,6 @@ class GatewayRunner:
|
||||
"Failed to deliver compression-failure warning to user: %s",
|
||||
_werr,
|
||||
)
|
||||
# Separately: if the user's CONFIGURED aux
|
||||
# model failed and we recovered by falling
|
||||
# back to the main model, tell them — a
|
||||
# misconfigured auxiliary.compression.model
|
||||
# is something only they can fix, and
|
||||
# silent recovery would hide it.
|
||||
elif _comp is not None and getattr(_comp, "_last_aux_model_failure_model", None):
|
||||
_aux_model = getattr(_comp, "_last_aux_model_failure_model", "")
|
||||
_aux_err = getattr(_comp, "_last_aux_model_failure_error", None) or "unknown error"
|
||||
_aux_msg = (
|
||||
f"ℹ️ Configured compression model `{_aux_model}` "
|
||||
f"failed ({_aux_err}). Recovered using your main "
|
||||
"model — context is intact — but you may want to "
|
||||
"check `auxiliary.compression.model` in config.yaml."
|
||||
)
|
||||
try:
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter and source.chat_id:
|
||||
await _adapter.send(source.chat_id, _aux_msg, metadata=_hyg_meta)
|
||||
except Exception as _werr:
|
||||
logger.warning(
|
||||
"Failed to deliver aux-model-fallback notice to user: %s",
|
||||
_werr,
|
||||
)
|
||||
finally:
|
||||
self._cleanup_agent_resources(_hyg_agent)
|
||||
|
||||
@@ -7401,11 +7377,6 @@ class GatewayRunner:
|
||||
_summary_failed = bool(getattr(compressor, "_last_summary_fallback_used", False))
|
||||
_dropped_count = int(getattr(compressor, "_last_summary_dropped_count", 0) or 0)
|
||||
_summary_err = getattr(compressor, "_last_summary_error", None)
|
||||
# Separately: did the user's CONFIGURED aux model fail
|
||||
# and we recovered via main? Surface that as an info
|
||||
# note so they can fix their config.
|
||||
_aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None)
|
||||
_aux_fail_err = getattr(compressor, "_last_aux_model_failure_error", None)
|
||||
finally:
|
||||
self._cleanup_agent_resources(tmp_agent)
|
||||
lines = [f"🗜️ {summary['headline']}"]
|
||||
@@ -7421,13 +7392,6 @@ class GatewayRunner:
|
||||
"with a placeholder; earlier context is no longer recoverable. "
|
||||
"Consider checking your auxiliary.compression model configuration."
|
||||
)
|
||||
elif _aux_fail_model:
|
||||
lines.append(
|
||||
f"ℹ️ Configured compression model `{_aux_fail_model}` failed "
|
||||
f"({_aux_fail_err or 'unknown error'}). Recovered using your main "
|
||||
"model — context is intact — but you may want to check "
|
||||
"`auxiliary.compression.model` in config.yaml."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.warning("Manual compress failed: %s", e)
|
||||
@@ -10041,7 +10005,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, "mention_user_id": source.user_id} if _progress_thread_id else {"mention_user_id": source.user_id}
|
||||
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
|
||||
def _status_callback_sync(event_type: str, message: str) -> None:
|
||||
if not _status_adapter or not _run_still_current():
|
||||
|
||||
+8
-52
@@ -56,18 +56,8 @@ _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_DM_AUTO_THREAD",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_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
|
||||
|
||||
@@ -952,7 +942,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": False,
|
||||
"redact_secrets": True,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
@@ -1702,30 +1692,6 @@ 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",
|
||||
@@ -1873,14 +1839,6 @@ 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)",
|
||||
@@ -3395,16 +3353,14 @@ def load_config() -> Dict[str, Any]:
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# 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: true
|
||||
# redact_secrets: false
|
||||
# tirith_enabled: true
|
||||
# tirith_path: "tirith"
|
||||
# tirith_timeout: 5
|
||||
@@ -3437,11 +3393,11 @@ _FALLBACK_COMMENT = """
|
||||
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: true
|
||||
# redact_secrets: false
|
||||
|
||||
# ── Fallback Model ────────────────────────────────────────────────────
|
||||
# Automatic provider failover when primary is unavailable.
|
||||
|
||||
+1
-5
@@ -9082,11 +9082,7 @@ Examples:
|
||||
)
|
||||
plugins_remove.add_argument("name", help="Plugin directory name to remove")
|
||||
|
||||
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_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
|
||||
|
||||
plugins_enable = plugins_subparsers.add_parser(
|
||||
"enable", help="Enable a disabled plugin"
|
||||
|
||||
@@ -79,20 +79,6 @@ VALID_HOOKS: Set[str] = {
|
||||
# {"action": "allow"} / None -> normal dispatch
|
||||
# Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store.
|
||||
"pre_gateway_dispatch",
|
||||
# Approval lifecycle hooks. Fired by tools/approval.py when a dangerous
|
||||
# command needs user approval -- fires BOTH for CLI-interactive prompts
|
||||
# and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.).
|
||||
# Observers only: return values are ignored. Plugins cannot veto or
|
||||
# pre-answer an approval from these hooks (use pre_tool_call to block
|
||||
# a tool before it reaches approval).
|
||||
#
|
||||
# Kwargs for pre_approval_request:
|
||||
# command: str, description: str, pattern_key: str, pattern_keys: list[str],
|
||||
# session_key: str, surface: "cli" | "gateway"
|
||||
# Kwargs for post_approval_response: same as above plus
|
||||
# choice: "once" | "session" | "always" | "deny" | "timeout"
|
||||
"pre_approval_request",
|
||||
"post_approval_response",
|
||||
}
|
||||
|
||||
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
||||
|
||||
+10
-168
@@ -1,13 +1,7 @@
|
||||
"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
|
||||
|
||||
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.
|
||||
Plugins are installed from Git repositories into ``~/.hermes/plugins/``.
|
||||
Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub).
|
||||
|
||||
After install, if the plugin ships an ``after-install.md`` file it is
|
||||
rendered with Rich Markdown. Otherwise a default confirmation is shown.
|
||||
@@ -101,80 +95,10 @@ def _resolve_git_url(identifier: str) -> str:
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid plugin identifier: '{identifier}'. "
|
||||
"Use 'official/<category>/<name>', a Git URL, or owner/repo shorthand."
|
||||
"Use 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
|
||||
@@ -372,61 +296,7 @@ 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:
|
||||
@@ -440,6 +310,8 @@ 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"
|
||||
@@ -824,21 +696,16 @@ def _discover_all_plugins() -> list:
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
def cmd_list() -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
entries = _discover_all_plugins()
|
||||
if not entries and not available:
|
||||
if not entries:
|
||||
console.print("[dim]No plugins installed.[/dim]")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install official/<category>/<name>")
|
||||
console.print("[dim]Browse available:[/dim] hermes plugins list --available")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
return
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
@@ -867,31 +734,6 @@ def cmd_list(available: bool = False) -> 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
|
||||
@@ -1428,7 +1270,7 @@ def plugins_command(args) -> None:
|
||||
elif action == "disable":
|
||||
cmd_disable(args.name)
|
||||
elif action in ("list", "ls"):
|
||||
cmd_list(available=getattr(args, "available", False))
|
||||
cmd_list()
|
||||
elif action is None:
|
||||
cmd_toggle()
|
||||
else:
|
||||
|
||||
@@ -425,31 +425,6 @@ 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.
|
||||
@@ -592,31 +567,6 @@ 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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
+104
-130
@@ -285,156 +285,130 @@ 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, 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.
|
||||
"""
|
||||
"""Create tables and FTS if they don't exist, run migrations."""
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
cursor.executescript(SCHEMA_SQL)
|
||||
|
||||
# ── 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.
|
||||
# Check schema version and run migrations
|
||||
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]
|
||||
# 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 < 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")
|
||||
if current_version < 10:
|
||||
# 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.
|
||||
# v10: trigram FTS5 table for CJK/substring search.
|
||||
# Created via FTS_TRIGRAM_SQL below; backfill existing messages.
|
||||
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"
|
||||
)
|
||||
if current_version < SCHEMA_VERSION:
|
||||
cursor.execute(
|
||||
"UPDATE schema_version SET version = ?",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
cursor.execute("UPDATE schema_version SET version = 10")
|
||||
|
||||
# Unique title index — always ensure it exists
|
||||
# Unique title index — always ensure it exists (safe to run after migrations
|
||||
# since the title column is guaranteed to exist at this point)
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||
|
||||
@@ -1,875 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
+1
-1
@@ -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", "aiohttp-socks>=0.10,<1"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"]
|
||||
cli = ["simple-term-menu>=1.0,<2"]
|
||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||
voice = [
|
||||
|
||||
@@ -8460,23 +8460,6 @@ class AIAgent:
|
||||
f"⚠ Compression summary failed: {summary_error}. "
|
||||
"Inserted a fallback context marker."
|
||||
)
|
||||
else:
|
||||
# No hard failure — but did the configured aux model error out
|
||||
# and get recovered by retrying on main? Surface that so users
|
||||
# know their auxiliary.compression.model setting is broken even
|
||||
# though compression succeeded.
|
||||
_aux_fail_model = getattr(self.context_compressor, "_last_aux_model_failure_model", None)
|
||||
_aux_fail_err = getattr(self.context_compressor, "_last_aux_model_failure_error", None)
|
||||
if _aux_fail_model:
|
||||
# Dedup on (model, error) so we don't spam on every compaction
|
||||
_aux_key = (_aux_fail_model, _aux_fail_err)
|
||||
if getattr(self, "_last_aux_fallback_warning_key", None) != _aux_key:
|
||||
self._last_aux_fallback_warning_key = _aux_key
|
||||
self._emit_warning(
|
||||
f"ℹ Configured compression model '{_aux_fail_model}' failed "
|
||||
f"({_aux_fail_err or 'unknown error'}). Recovered using main model — "
|
||||
"check auxiliary.compression.model in config.yaml."
|
||||
)
|
||||
|
||||
todo_snapshot = self._todo_store.format_for_injection()
|
||||
if todo_snapshot:
|
||||
|
||||
@@ -43,13 +43,6 @@ 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
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
hermes config set security.redact_secrets true # enable globally
|
||||
hermes config set security.redact_secrets false # disable globally
|
||||
```
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
Disable again with:
|
||||
Re-enable with:
|
||||
```bash
|
||||
hermes config set security.redact_secrets false
|
||||
hermes config set security.redact_secrets true
|
||||
```
|
||||
|
||||
### PII redaction in gateway messages
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,577 +0,0 @@
|
||||
---
|
||||
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."
|
||||
@@ -204,9 +204,8 @@ win.par.winopen.pulse()
|
||||
| `td_input_clear` | Stop input automation |
|
||||
| `td_op_screen_rect` | Get screen coords of a node |
|
||||
| `td_click_screen_point` | Click a point in a screenshot |
|
||||
| `td_screen_point_to_global` | Convert screenshot pixel to absolute screen coords |
|
||||
|
||||
The table above covers the 32 tools used in typical creative workflows. The remaining 4 tools (`td_project_quit`, `td_test_session`, `td_dev_log`, `td_clear_dev_log`) are admin/dev-mode utilities — see `references/mcp-tools.md` for the full 36-tool reference with complete parameter schemas.
|
||||
See `references/mcp-tools.md` for full parameter schemas.
|
||||
|
||||
## Key Implementation Rules
|
||||
|
||||
@@ -339,15 +338,6 @@ See `references/network-patterns.md` for complete build scripts + shader code.
|
||||
| `references/operator-tips.md` | Wireframe rendering, feedback TOP setup |
|
||||
| `references/geometry-comp.md` | Geometry COMP: instancing, POP vs SOP, morphing |
|
||||
| `references/audio-reactive.md` | Audio band extraction, beat detection, envelope following |
|
||||
| `references/animation.md` | LFOs, timers, keyframes, easing, expression-driven motion |
|
||||
| `references/midi-osc.md` | MIDI/OSC controllers, TouchOSC, multi-machine sync |
|
||||
| `references/particles.md` | POPs and legacy particleSOP — emission, forces, collisions |
|
||||
| `references/projection-mapping.md` | Multi-window output, corner pin, mesh warp, edge blending |
|
||||
| `references/external-data.md` | HTTP, WebSocket, MQTT, Serial, TCP, webserverDAT |
|
||||
| `references/panel-ui.md` | Custom params, panel COMPs, button/slider/field, panelExecuteDAT |
|
||||
| `references/replicator.md` | replicatorCOMP — data-driven cloning, layouts, callbacks |
|
||||
| `references/dat-scripting.md` | Execute DAT family — chop/dat/parameter/panel/op/executeDAT |
|
||||
| `references/3d-scene.md` | Lighting rigs, shadows, IBL/cubemaps, multi-camera, PBR |
|
||||
| `scripts/setup.sh` | Automated setup script |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# 3D Scene Reference
|
||||
|
||||
Lighting rigs, shadows, IBL/cubemaps, multi-camera, and PBR materials. For wireframe rendering and feedback TOPs see `operator-tips.md`. For instancing geometry see `geometry-comp.md`. For shader code see `glsl.md`.
|
||||
|
||||
---
|
||||
|
||||
## Anatomy of a 3D Scene
|
||||
|
||||
```
|
||||
[Geometry COMP] ← contains SOPs (the shapes)
|
||||
[Material] ← Phong/PBR/GLSL/Constant MAT
|
||||
[Light COMPs] ← point/directional/spot/area/environment
|
||||
[Camera COMP] ← view position, FOV
|
||||
│
|
||||
▼
|
||||
[Render TOP] ← combines geo + lights + camera into a 2D image
|
||||
│
|
||||
▼
|
||||
[post-FX chain] ← bloomTOP, glsl shaders, etc.
|
||||
│
|
||||
▼
|
||||
[windowCOMP] ← actual display
|
||||
```
|
||||
|
||||
Render TOP is the heart. It takes an explicit `geometry` path, an explicit `camera` path, and lights via the lights table or an envlight reference.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Scene
|
||||
|
||||
```python
|
||||
# Geometry
|
||||
geo = root.create(geometryCOMP, 'scene_geo')
|
||||
sphere = geo.create(sphereSOP, 'shape')
|
||||
sphere.par.rad = 1.0; sphere.par.rows = 64; sphere.par.cols = 64
|
||||
|
||||
# Material — start with PBR
|
||||
mat = root.create(pbrMAT, 'mat')
|
||||
mat.par.basecolorr = 0.7; mat.par.basecolorg = 0.7; mat.par.basecolorb = 0.7
|
||||
mat.par.metallic = 0.0
|
||||
mat.par.roughness = 0.4
|
||||
|
||||
geo.par.material = mat.path
|
||||
|
||||
# Camera
|
||||
cam = root.create(cameraCOMP, 'cam1')
|
||||
cam.par.tx = 0; cam.par.ty = 0; cam.par.tz = 4
|
||||
cam.par.fov = 45
|
||||
cam.par.near = 0.1; cam.par.far = 100
|
||||
|
||||
# Key light
|
||||
key = root.create(lightCOMP, 'key_light')
|
||||
key.par.lighttype = 'point'
|
||||
key.par.tx = 3; key.par.ty = 3; key.par.tz = 3
|
||||
key.par.dimmer = 1.5
|
||||
|
||||
# Render
|
||||
render = root.create(renderTOP, 'render1')
|
||||
render.par.outputresolution = 'custom'
|
||||
render.par.resolutionw = 1920; render.par.resolutionh = 1080
|
||||
render.par.camera = cam.path
|
||||
render.par.geometry = geo.path
|
||||
render.par.lights = key.path # single light path; for multi, see below
|
||||
render.par.bgcolorr = 0; render.par.bgcolorg = 0; render.par.bgcolorb = 0
|
||||
```
|
||||
|
||||
For multiple lights, leave `par.lights` blank — Render TOP scans the network for all `lightCOMP` and `envlightCOMP` ops by default. To restrict to specific lights, set `par.lights = '/project1/key_light /project1/fill_light'` (space-separated paths).
|
||||
|
||||
---
|
||||
|
||||
## Light Types
|
||||
|
||||
| Type | What | Common params |
|
||||
|---|---|---|
|
||||
| `point` | Omnidirectional, falls off with distance | `dimmer`, `coneangle` (n/a), `attenuation` |
|
||||
| `directional` | Parallel rays, infinite distance (sun) | `dimmer`, light's rotation only matters |
|
||||
| `spot` | Cone, falls off with distance + angle | `coneangle`, `conedelta`, `dimmer` |
|
||||
| `cone` | Like spot but harder edge | same |
|
||||
| `area` | Rectangular soft light source | `sizex`, `sizey` |
|
||||
|
||||
For all: `colorr`, `colorg`, `colorb`, `tx/ty/tz`, `rx/ry/rz`, `dimmer`.
|
||||
|
||||
### Three-Point Lighting (Studio Setup)
|
||||
|
||||
```python
|
||||
# Key — main light, ~45° front
|
||||
key = root.create(lightCOMP, 'key')
|
||||
key.par.lighttype = 'point'
|
||||
key.par.tx = 4; key.par.ty = 3; key.par.tz = 4
|
||||
key.par.dimmer = 1.5
|
||||
key.par.colorr = 1.0; key.par.colorg = 0.95; key.par.colorb = 0.85
|
||||
|
||||
# Fill — softer, opposite side
|
||||
fill = root.create(lightCOMP, 'fill')
|
||||
fill.par.lighttype = 'area'
|
||||
fill.par.tx = -4; fill.par.ty = 2; fill.par.tz = 3
|
||||
fill.par.dimmer = 0.5
|
||||
fill.par.colorr = 0.7; fill.par.colorg = 0.8; fill.par.colorb = 1.0
|
||||
fill.par.sizex = 4; fill.par.sizey = 4
|
||||
|
||||
# Rim/back — outline from behind
|
||||
rim = root.create(lightCOMP, 'rim')
|
||||
rim.par.lighttype = 'spot'
|
||||
rim.par.tx = 0; rim.par.ty = 4; rim.par.tz = -4
|
||||
rim.par.coneangle = 30
|
||||
rim.par.dimmer = 1.0
|
||||
|
||||
# Optional: ambient lift to prevent pure-black shadows
|
||||
amb = root.create(ambientlightCOMP, 'ambient')
|
||||
amb.par.dimmer = 0.15
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shadows
|
||||
|
||||
Spot and directional lights cast shadows when `par.shadowtype != 'none'`.
|
||||
|
||||
```python
|
||||
key.par.shadowtype = 'softshadow' # 'none' | 'hardshadow' | 'softshadow'
|
||||
key.par.shadowsize = 1024 # shadow map resolution
|
||||
key.par.shadowsoftness = 0.02 # softshadow only
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
- Soft shadows are GPU-expensive. Start with `shadowsize = 1024` and only go higher (2048/4096) if shadow edges look pixelated at your resolution.
|
||||
- Set the spot light's `near`/`far` to JUST contain the scene. Wider range = wasted shadow map precision.
|
||||
- Multiple shadow-casting lights compound cost. Limit to 1-2 in real-time work; pre-bake the rest into the materials.
|
||||
|
||||
---
|
||||
|
||||
## Image-Based Lighting (IBL) / Environment Light
|
||||
|
||||
For realistic PBR materials you need a cubemap for reflections.
|
||||
|
||||
```python
|
||||
# Environment light from an HDR
|
||||
env = root.create(envlightCOMP, 'env')
|
||||
env.par.envmap = '/project1/cube_in' # path to a TOP that produces a cubemap
|
||||
env.par.envlightmap = ... # diffuse irradiance map (often same as envmap)
|
||||
env.par.dimmer = 1.0
|
||||
|
||||
# Cubemap source — option A: built-in cubeTOP from 6 faces
|
||||
cube = root.create(cubeTOP, 'cube_in')
|
||||
# (assign 6 face TOPs)
|
||||
|
||||
# Option B: HDR equirectangular → cubemap conversion
|
||||
# Use a moviefileinTOP loading .hdr or .exr, then projectTOP type='cubemapfromequirect'
|
||||
hdr = root.create(moviefileinTOP, 'hdr_src')
|
||||
hdr.par.file = '/path/to/environment.hdr'
|
||||
|
||||
proj = root.create(projectTOP, 'cube_proj')
|
||||
proj.par.projecttype = 'cubemapfromequirect'
|
||||
proj.inputConnectors[0].connect(hdr)
|
||||
```
|
||||
|
||||
PBR materials sample the environment automatically when `envlightCOMP` is in the scene. Verify param names with `td_get_par_info(op_type='envlightCOMP')` — TD versions vary.
|
||||
|
||||
---
|
||||
|
||||
## PBR Material Setup
|
||||
|
||||
```python
|
||||
mat = root.create(pbrMAT, 'pbr_metal')
|
||||
mat.par.basecolorr = 0.95; mat.par.basecolorg = 0.65; mat.par.basecolorb = 0.4
|
||||
mat.par.metallic = 1.0
|
||||
mat.par.roughness = 0.25
|
||||
mat.par.specularlevel = 0.5
|
||||
mat.par.emitcolorr = 0; mat.par.emitcolorg = 0; mat.par.emitcolorb = 0
|
||||
|
||||
# Texture maps
|
||||
mat.par.basecolormap = '/project1/textures/albedo' # TOP path
|
||||
mat.par.metallicroughnessmap = '/project1/textures/mr' # G=roughness, B=metallic (glTF convention)
|
||||
mat.par.normalmap = '/project1/textures/normal'
|
||||
mat.par.emitmap = '/project1/textures/emit'
|
||||
mat.par.occlusionmap = '/project1/textures/ao'
|
||||
```
|
||||
|
||||
**Material idioms:**
|
||||
|
||||
| Look | metallic | roughness | basecolor |
|
||||
|---|---|---|---|
|
||||
| Brushed steel | 1.0 | 0.4 | (0.7, 0.7, 0.7) |
|
||||
| Polished gold | 1.0 | 0.1 | (1.0, 0.85, 0.4) |
|
||||
| Plastic | 0.0 | 0.5 | mid-saturated |
|
||||
| Rubber | 0.0 | 0.9 | dark |
|
||||
| Glass | 0.0 | 0.05 | (1, 1, 1), low alpha + transmission |
|
||||
| Glowing emitter | 0.0 | 1.0 | dark, high `emitcolor` |
|
||||
|
||||
For glass/transmission, recent TD versions support `transmission` in PBR; older versions need glslMAT.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Camera Setups
|
||||
|
||||
For comparison views, instant replay, multi-screen mapping, etc.
|
||||
|
||||
```python
|
||||
# Camera A — main scene
|
||||
cam_a = root.create(cameraCOMP, 'cam_main')
|
||||
cam_a.par.tz = 5
|
||||
|
||||
# Camera B — orbiting top-down
|
||||
cam_b = root.create(cameraCOMP, 'cam_top')
|
||||
cam_b.par.ty = 6; cam_b.par.rx = -90
|
||||
|
||||
# Render each via separate Render TOPs
|
||||
render_a = root.create(renderTOP, 'render_main')
|
||||
render_a.par.camera = cam_a.path
|
||||
render_a.par.geometry = geo.path
|
||||
|
||||
render_b = root.create(renderTOP, 'render_top')
|
||||
render_b.par.camera = cam_b.path
|
||||
render_b.par.geometry = geo.path
|
||||
```
|
||||
|
||||
Composite both with a `multiplyTOP`/`compositeTOP` for picture-in-picture, or route to separate `windowCOMP`s for multi-display.
|
||||
|
||||
### Camera animation
|
||||
|
||||
Drive camera params via expressions (orbit), animationCOMP (waypoint), or LFO (oscillation):
|
||||
|
||||
```python
|
||||
# Orbiting camera
|
||||
cam_a.par.tx.mode = ParMode.EXPRESSION
|
||||
cam_a.par.tx.expr = "cos(absTime.seconds * 0.3) * 6"
|
||||
cam_a.par.tz.mode = ParMode.EXPRESSION
|
||||
cam_a.par.tz.expr = "sin(absTime.seconds * 0.3) * 6"
|
||||
cam_a.par.lookat = '/project1/scene_geo' # auto-aim at target
|
||||
```
|
||||
|
||||
`par.lookat` is the simplest "always look at target" mechanism.
|
||||
|
||||
### Depth of field
|
||||
|
||||
PBR + Render TOP supports DOF when `par.dof = 'on'`.
|
||||
|
||||
```python
|
||||
render.par.dof = 'on'
|
||||
render.par.focusdistance = 5.0
|
||||
render.par.aperture = 0.05 # blur strength
|
||||
render.par.bokehshape = 'hexagon'
|
||||
```
|
||||
|
||||
DOF is GPU-heavy. Render at lower res then upscale for performance.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Render TOP shows black** — most common cause: no light. Even with PBR you need at least one `lightCOMP` or `envlightCOMP`. Add an `ambientlightCOMP` at low dimmer as a safety net.
|
||||
2. **Material doesn't appear** — `geo.par.material` must be a string PATH, not the material op itself. Use `mat.path`, not `mat`.
|
||||
3. **Lights ignored** — by default Render TOP picks up ALL `lightCOMP`s in the network. If you have leftover lights from another scene, they leak in. Set `par.lights` explicitly.
|
||||
4. **PBR looks flat** — without an `envlightCOMP` providing reflections, PBR materials look like Phong. Add one even if you don't have an HDR (use a `constantTOP` cubemap as fallback).
|
||||
5. **Shadow acne / striping** — increase `par.shadowbias` slightly. Tune per-light.
|
||||
6. **Camera inside geometry** — if `cam.par.tz` is INSIDE a sphere, you see the inside (or nothing if backface culled). Move the camera further out.
|
||||
7. **Light range too small** — point lights have implicit attenuation. Far-away geometry receives little light. Increase `par.dimmer` or move lights closer.
|
||||
8. **Multiple cameras conflict** — one render TOP = one camera. Don't try to share. Use multiple render TOPs.
|
||||
9. **Wrong handedness** — TD is right-handed Y-up. Imported assets from Z-up apps (Blender, Maya in Z-up) need a 90° X rotation on the geo COMP.
|
||||
10. **Cooking budget** — PBR + IBL + shadows + DOF at 1080p60 is fine on modern GPUs but 4K + 4 lights + soft shadows + DOF will tank. Profile via `td_get_perf` and downgrade settings before adding more.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Recipe |
|
||||
|---|---|
|
||||
| Studio portrait | 3-point rig (key + fill + rim) + ambient + PBR mat + DOF |
|
||||
| Outdoor daylight | One directional `lightCOMP` (sun) + envlight (sky HDR) + soft shadows |
|
||||
| Dramatic / film noir | Single spot light from upper side, hard shadows, deep ambient = 0.05 |
|
||||
| Abstract / dreamy | Multiple area lights at low dimmer, no shadows, `bloomTOP` post |
|
||||
| Product render | Three-point + IBL + neutral PBR + `bgcolorr=g=b=1` (white seamless) |
|
||||
| Game-style | Phong MAT + 1-2 lights + no IBL + flat ambient (cheap, stylized) |
|
||||
| Wireframe + solid | Two render TOPs (one with wireframeMAT, one with PBR), composite via `addTOP` |
|
||||
| Orbiting camera | `par.lookat` + expressions on tx/tz using sin/cos |
|
||||
@@ -1,221 +0,0 @@
|
||||
# Animation Reference
|
||||
|
||||
Patterns for time-based motion — keyframes, LFOs, timers, easing, expression-driven animation.
|
||||
|
||||
Always call `td_get_par_info` for the op type before setting params. Param names below reflect TD 2025.32 but verify if errors fire.
|
||||
|
||||
---
|
||||
|
||||
## Time Sources
|
||||
|
||||
TD has three time references — pick the right one.
|
||||
|
||||
| Expression | Behavior | Use for |
|
||||
|---|---|---|
|
||||
| `absTime.seconds` | Wall-clock seconds since TD started. Never resets. | Continuous motion, GLSL `uTime`, infinite loops |
|
||||
| `absTime.frame` | Wall-clock frame count. | Frame-accurate triggers |
|
||||
| `me.time.frame` | Local component frame count (resets on play/stop). | Per-COMP animation timeline |
|
||||
| `me.time.seconds` | Local component seconds. | Same, in seconds |
|
||||
|
||||
**Rule:** for shaders and continuous motion use `absTime.seconds`. For triggered/looping animations inside a COMP use `me.time.*`.
|
||||
|
||||
---
|
||||
|
||||
## LFO CHOP — Cyclic Motion
|
||||
|
||||
The simplest periodic driver. Fast, GPU-cheap, expression-friendly.
|
||||
|
||||
```python
|
||||
lfo = root.create(lfoCHOP, 'rot_driver')
|
||||
lfo.par.type = 'sin' # 'sin' | 'cos' | 'ramp' | 'square' | 'triangle' | 'pulse'
|
||||
lfo.par.frequency = 0.25 # cycles per second
|
||||
lfo.par.amplitude = 1.0
|
||||
lfo.par.offset = 0.0
|
||||
lfo.par.phase = 0.0 # 0-1, useful for offsetting parallel LFOs
|
||||
```
|
||||
|
||||
**Drive a parameter via export:**
|
||||
|
||||
```python
|
||||
op('/project1/geo1').par.rx.mode = ParMode.EXPRESSION
|
||||
op('/project1/geo1').par.rx.expr = "op('rot_driver')['chan1'] * 360"
|
||||
```
|
||||
|
||||
**Multiple synced LFOs (X/Y/Z rotation with phase offsets):**
|
||||
Create one LFO with three channels and phase-offset each, or use three LFOs and offset their `phase` params (0.0, 0.33, 0.66).
|
||||
|
||||
---
|
||||
|
||||
## Timer CHOP — Triggered Sequences
|
||||
|
||||
For run-once animations, beat-locked sequences, or stage-based logic.
|
||||
|
||||
```python
|
||||
timer = root.create(timerCHOP, 'fade_timer')
|
||||
timer.par.length = 4.0 # cycle length in seconds
|
||||
timer.par.cycle = False # run once vs. loop
|
||||
timer.par.outputseconds = True
|
||||
```
|
||||
|
||||
Output channels: `timer_fraction` (0→1 across the cycle), `running`, `done`, `cycles`.
|
||||
|
||||
**Start the timer:**
|
||||
```python
|
||||
timer.par.start.pulse()
|
||||
```
|
||||
|
||||
**Drive a fade:**
|
||||
```python
|
||||
op('/project1/level1').par.opacity.mode = ParMode.EXPRESSION
|
||||
op('/project1/level1').par.opacity.expr = "op('fade_timer')['timer_fraction']"
|
||||
```
|
||||
|
||||
**Easing on the timer fraction** — apply in the expression itself:
|
||||
|
||||
```python
|
||||
# Smoothstep: ease in/out
|
||||
expr = "smoothstep(0, 1, op('fade_timer')['timer_fraction'])"
|
||||
# Cubic ease-out: 1 - (1-t)^3
|
||||
expr = "1 - pow(1 - op('fade_timer')['timer_fraction'], 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern CHOP — Custom Curves
|
||||
|
||||
For arbitrary waveforms (saw ramps, easing curves, custom envelopes).
|
||||
|
||||
```python
|
||||
pat = root.create(patternCHOP, 'envelope')
|
||||
pat.par.type = 'gaussian' # 'gaussian' | 'ramp' | 'square' | 'sin' | etc.
|
||||
pat.par.length = 60 # samples
|
||||
pat.par.cyclelength = 1.0 # seconds at TD framerate
|
||||
```
|
||||
|
||||
Combine with `lookupCHOP` to remap a 0-1 driver through a custom curve.
|
||||
|
||||
---
|
||||
|
||||
## Animation COMP — Keyframe-Based
|
||||
|
||||
For multi-keyframe motion graphics. Each animationCOMP holds channels with keyframes editable in the Animation Editor.
|
||||
|
||||
```python
|
||||
anim = root.create(animationCOMP, 'intro_anim')
|
||||
# By default has channels chan1..chanN; access via:
|
||||
# op('intro_anim').par.length, .par.play, .par.cue, etc.
|
||||
|
||||
# Drive a parameter from a channel
|
||||
op('/project1/text1').par.tx.mode = ParMode.EXPRESSION
|
||||
op('/project1/text1').par.tx.expr = "op('intro_anim/out1')['chan1']"
|
||||
```
|
||||
|
||||
**Keyframes are typically edited in the UI** (Animation Editor), but can be set via `keyframes` table internally. For programmatic keyframe creation, use `td_execute_python`:
|
||||
|
||||
```python
|
||||
# Get the channel CHOP inside an animationCOMP
|
||||
ch = op('/project1/intro_anim/chans')
|
||||
# Insert a key (advanced API — verify with td_get_par_info(op_type='animationCOMP'))
|
||||
ch.appendKey('chan1', frame=0, value=0.0, expression=None)
|
||||
ch.appendKey('chan1', frame=120, value=1.0)
|
||||
```
|
||||
|
||||
For most use cases, drive params with LFO/Timer/Pattern CHOPs instead — simpler and scriptable.
|
||||
|
||||
---
|
||||
|
||||
## Easing in Expressions
|
||||
|
||||
TD's expression evaluator supports Python math. Common easing forms:
|
||||
|
||||
```python
|
||||
# Linear
|
||||
"t"
|
||||
|
||||
# Smoothstep (classic ease-in-out)
|
||||
"smoothstep(0, 1, t)"
|
||||
|
||||
# Ease-out cubic
|
||||
"1 - pow(1 - t, 3)"
|
||||
|
||||
# Ease-in cubic
|
||||
"pow(t, 3)"
|
||||
|
||||
# Ease-in-out cubic
|
||||
"3*t*t - 2*t*t*t"
|
||||
|
||||
# Bounce (manual, simplified)
|
||||
"abs(sin(t * 6.28 * 3) * (1 - t))"
|
||||
```
|
||||
|
||||
Where `t` is `op('fade_timer')['timer_fraction']` or any 0-1 driver.
|
||||
|
||||
---
|
||||
|
||||
## Filter CHOP — Smoothing Existing Channels
|
||||
|
||||
Smooth out jittery values (e.g., audio analysis, sensor data) before driving visuals.
|
||||
|
||||
```python
|
||||
filt = root.create(filterCHOP, 'smooth')
|
||||
filt.par.filter = 'gaussian' # or 'lowpass'
|
||||
filt.par.width = 0.5 # smoothing window in seconds
|
||||
filt.inputConnectors[0].connect(op('raw_signal'))
|
||||
```
|
||||
|
||||
**WARNING:** Do NOT use Filter CHOP on AudioSpectrum output in timeslice mode — it expands the sample count and averages bins to near-zero. See `audio-reactive.md`.
|
||||
|
||||
---
|
||||
|
||||
## Lag CHOP — Asymmetric Attack/Release
|
||||
|
||||
Different speeds for rising vs. falling values. Standard for visualizing audio envelopes.
|
||||
|
||||
```python
|
||||
lag = root.create(lagCHOP, 'env_smooth')
|
||||
lag.par.lag1 = 0.02 # attack (rise time, seconds)
|
||||
lag.par.lag2 = 0.30 # release (fall time, seconds)
|
||||
lag.inputConnectors[0].connect(op('raw_envelope'))
|
||||
```
|
||||
|
||||
Fast attack, slow release = classic VU-meter feel.
|
||||
|
||||
---
|
||||
|
||||
## Per-Frame Driving via Script DAT
|
||||
|
||||
For complex per-frame logic that doesn't fit expressions, use a `executeDAT` (`onFrameStart` callback) or a `chopExecuteDAT`.
|
||||
|
||||
```python
|
||||
# In an executeDAT (frameStart):
|
||||
def onFrameStart(frame):
|
||||
t = absTime.seconds
|
||||
op('/project1/circle').par.tx = math.sin(t * 2.0) * 3.0
|
||||
op('/project1/circle').par.ty = math.cos(t * 2.0) * 3.0
|
||||
return
|
||||
```
|
||||
|
||||
Heavy logic should still be in CHOPs (CPU-cheap, deterministic). Reserve scripts for one-shots or non-realtime branching.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Frame rate dependency** — `me.time.frame` is in TD project frames (default 60). If your project rate changes, motion speed changes. Use `seconds` for rate-independent timing.
|
||||
2. **Cooking budget** — every CHOP that drives a parameter cooks every frame. Consolidate drivers (one big mathCHOP > many small ones).
|
||||
3. **Expression mode** — params default to `CONSTANT`. `par.X.expr = ...` is ignored unless `par.X.mode = ParMode.EXPRESSION`.
|
||||
4. **Animation editor edits** — keyframes set via UI live in the animationCOMP's internal keyframe table. They survive save/reopen. Programmatic keys via `appendKey()` work but verify the API with `td_get_docs(topic='animation')` first.
|
||||
5. **Looping animations** — for seamless loops, `length` must equal `cyclelength` and the start/end values must match. Otherwise expect a visible jump.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Simplest path |
|
||||
|---|---|
|
||||
| Continuous rotation | LFO CHOP `type='ramp'`, expr → `geo.par.rx` |
|
||||
| Fade in over 2s | Timer CHOP `length=2`, smoothstep expr → `level.par.opacity` |
|
||||
| Pulse on every beat | `triggerCHOP` from audio → drive scale via expression |
|
||||
| 3D Lissajous orbit | Two LFOs with different freq, drive `tx`/`ty`/`tz` |
|
||||
| Random jitter | `noiseCHOP` (low-freq) added to position |
|
||||
| Timed scene switch | Timer CHOP → switchTOP/CHOP `index` |
|
||||
@@ -1,352 +0,0 @@
|
||||
# DAT-Based Scripting Reference
|
||||
|
||||
TD's event/callback model — Python that runs in response to network events. The full set of "Execute DATs" plus their idiomatic patterns.
|
||||
|
||||
For arbitrary Python execution (not callback-based), see `python-api.md`. For the MCP's `td_execute_python` tool, see `mcp-tools.md`.
|
||||
|
||||
---
|
||||
|
||||
## The Execute DAT Family
|
||||
|
||||
Every type watches one kind of event source and fires Python on changes.
|
||||
|
||||
| DAT | Watches | Use for |
|
||||
|---|---|---|
|
||||
| `chopExecuteDAT` | A CHOP's channel values | Audio triggers, threshold callbacks, state machines on numeric input |
|
||||
| `datExecuteDAT` | A DAT's content (table cells, text) | Reacting to data updates from APIs, parsing webDAT responses |
|
||||
| `parameterExecuteDAT` | A parameter's value or pulse | Reacting to user-changed params, custom pulse buttons |
|
||||
| `panelExecuteDAT` | A panel COMP's interaction | Button clicks, slider drags, field commits |
|
||||
| `opExecuteDAT` | Operator lifecycle | New operator created, deleted, name changed |
|
||||
| `executeDAT` | Project lifecycle, frame events | Run-once setup, per-frame logic, save/load hooks |
|
||||
|
||||
All have a docked DAT with predefined callback functions. You only fill in the bodies of the ones you care about.
|
||||
|
||||
---
|
||||
|
||||
## chopExecuteDAT — Numeric Triggers
|
||||
|
||||
```python
|
||||
ce = root.create(chopExecuteDAT, 'kick_handler')
|
||||
ce.par.chop = '/project1/audio/out_kick' # source CHOP
|
||||
ce.par.offtoon = True # fire when channel rises above 0
|
||||
ce.par.ontooff = False
|
||||
ce.par.whileon = False
|
||||
ce.par.valuechange = False
|
||||
```
|
||||
|
||||
In the docked callback DAT:
|
||||
|
||||
```python
|
||||
def offToOn(channel, sampleIndex, val, prev):
|
||||
"""Channel went from 0 to non-zero. Classic beat trigger."""
|
||||
op('/project1/strobe').par.flash.pulse()
|
||||
op('/project1/scene').par.index = (op('/project1/scene').par.index + 1) % 8
|
||||
return
|
||||
|
||||
def onToOff(channel, sampleIndex, val, prev):
|
||||
"""Channel went from non-zero to 0."""
|
||||
return
|
||||
|
||||
def whileOn(channel, sampleIndex, val, prev):
|
||||
"""Fires every frame while channel is non-zero. Use sparingly."""
|
||||
return
|
||||
|
||||
def valueChange(channel, sampleIndex, val, prev):
|
||||
"""Fires every frame the value changes (continuous). Heavy."""
|
||||
return
|
||||
```
|
||||
|
||||
`channel` is a `Channel` object — `.name`, `.owner`, `.vals[]`. Use `channel.name == 'chan1'` to filter.
|
||||
|
||||
**Threshold-based custom triggers:** wire the source CHOP through a `triggerCHOP` first to get clean 0/1 pulses, then watch with `offtoon`.
|
||||
|
||||
---
|
||||
|
||||
## datExecuteDAT — Table/Text Changes
|
||||
|
||||
```python
|
||||
de = root.create(datExecuteDAT, 'api_response')
|
||||
de.par.dat = '/project1/api/web1' # source DAT
|
||||
de.par.tablechange = True # any cell change
|
||||
de.par.cellchange = False
|
||||
de.par.rowchange = False
|
||||
de.par.colchange = False
|
||||
```
|
||||
|
||||
```python
|
||||
def onTableChange(dat):
|
||||
"""Whole table changed (including text DAT content updates)."""
|
||||
if dat.numRows == 0:
|
||||
return
|
||||
# If it's a webDAT response, parse JSON
|
||||
import json
|
||||
try:
|
||||
data = json.loads(dat.text)
|
||||
except json.JSONDecodeError:
|
||||
debug(f'Bad JSON: {dat.text[:100]}')
|
||||
return
|
||||
# Write to a CHOP
|
||||
op('/project1/api_value').par.value0 = float(data.get('count', 0))
|
||||
return
|
||||
|
||||
def onCellChange(dat, cells, prev):
|
||||
"""Specific cells changed."""
|
||||
for cell in cells:
|
||||
# cell.row, cell.col, cell.val
|
||||
pass
|
||||
return
|
||||
```
|
||||
|
||||
`debug()` prints to the textport — readable via `td_read_textport`.
|
||||
|
||||
---
|
||||
|
||||
## parameterExecuteDAT — Param Changes & Pulse
|
||||
|
||||
```python
|
||||
pe = root.create(parameterExecuteDAT, 'comp_params')
|
||||
pe.par.op = '/project1/my_component' # COMP whose params to watch
|
||||
pe.par.parameters = '*' # or specific names like 'Intensity Reset'
|
||||
pe.par.valuechange = True
|
||||
pe.par.pulse = True
|
||||
```
|
||||
|
||||
```python
|
||||
def onValueChange(par, prev):
|
||||
"""par is a Par object. par.name, par.eval(), par.owner."""
|
||||
if par.name == 'Intensity':
|
||||
op('/project1/bloom').par.threshold = par.eval()
|
||||
return
|
||||
|
||||
def onPulse(par):
|
||||
"""Pulse param was triggered."""
|
||||
if par.name == 'Reset':
|
||||
op('/project1/scene').par.index = 0
|
||||
op('/project1/audio_player').par.cuepoint = 0
|
||||
op('/project1/audio_player').par.cuepulse.pulse()
|
||||
return
|
||||
|
||||
def onExpressionChange(par, val, prev):
|
||||
"""User changed the expression on a param."""
|
||||
return
|
||||
|
||||
def onExportChange(par, val, prev):
|
||||
"""Export source changed."""
|
||||
return
|
||||
|
||||
def onModeChange(par, val, prev):
|
||||
"""Param mode changed (CONSTANT / EXPRESSION / EXPORT / etc)."""
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## panelExecuteDAT — UI Events
|
||||
|
||||
For interactive control surfaces. See `panel-ui.md` for the full panel COMP context.
|
||||
|
||||
```python
|
||||
pe = root.create(panelExecuteDAT, 'btn_handler')
|
||||
pe.par.panel = '/project1/play_btn'
|
||||
pe.par.click = True # mouse click events
|
||||
pe.par.value = True # state changes (toggle)
|
||||
pe.par.lockedchange = False
|
||||
```
|
||||
|
||||
```python
|
||||
def onOffToOn(panelValue):
|
||||
"""Panel value rose to 1 (button pressed, slider crossed threshold)."""
|
||||
op('/project1/scene_timer').par.start.pulse()
|
||||
return
|
||||
|
||||
def onOnToOff(panelValue):
|
||||
"""Panel value dropped to 0."""
|
||||
return
|
||||
|
||||
def onValueChange(panelValue):
|
||||
"""Continuous: every frame the value changes."""
|
||||
val = panelValue.eval()
|
||||
op('/project1/master').par.opacity = val
|
||||
return
|
||||
|
||||
def onClick(panelValue):
|
||||
"""Discrete click event, fires once per click."""
|
||||
return
|
||||
```
|
||||
|
||||
`panelValue` is a `Par` object on the panel COMP.
|
||||
|
||||
---
|
||||
|
||||
## opExecuteDAT — Operator Lifecycle
|
||||
|
||||
Watches creation/deletion/renaming of operators in a parent COMP.
|
||||
|
||||
```python
|
||||
oe = root.create(opExecuteDAT, 'lifecycle')
|
||||
oe.par.op = '/project1'
|
||||
oe.par.create = True
|
||||
oe.par.destroy = True
|
||||
oe.par.namechange = True
|
||||
oe.par.flagchange = False
|
||||
```
|
||||
|
||||
```python
|
||||
def onCreate(opCreated):
|
||||
"""A new operator was created. Useful for auto-applying conventions."""
|
||||
if opCreated.OPType == 'glslTOP':
|
||||
# Always wrap with a null
|
||||
n = opCreated.parent().create(nullTOP, opCreated.name + '_out')
|
||||
n.inputConnectors[0].connect(opCreated)
|
||||
return
|
||||
|
||||
def onDestroy(opDestroyed):
|
||||
"""Operator was deleted. opDestroyed.path is still valid for one frame."""
|
||||
return
|
||||
|
||||
def onNameChange(opChanged):
|
||||
"""Operator was renamed."""
|
||||
return
|
||||
```
|
||||
|
||||
Useful for dev-time scaffolding (auto-create downstream nullTOPs, auto-name conventions). Disable in production projects to avoid surprise side effects.
|
||||
|
||||
---
|
||||
|
||||
## executeDAT — Project Lifecycle & Per-Frame
|
||||
|
||||
The catch-all. Gets you hooks into project start, save, load, frame-start, frame-end.
|
||||
|
||||
```python
|
||||
exec_dat = root.create(executeDAT, 'lifecycle')
|
||||
exec_dat.par.start = True
|
||||
exec_dat.par.create = True
|
||||
exec_dat.par.framestart = True
|
||||
exec_dat.par.frameend = False
|
||||
```
|
||||
|
||||
```python
|
||||
def onStart():
|
||||
"""Project just started cooking. Run once."""
|
||||
op('/project1/scene').par.index = 0
|
||||
debug('Project started')
|
||||
return
|
||||
|
||||
def onCreate():
|
||||
"""Component was just created (only fires for component executeDATs, not project root)."""
|
||||
return
|
||||
|
||||
def onFrameStart(frame):
|
||||
"""Per-frame, BEFORE network cooks. Heavy logic here = bottleneck."""
|
||||
return
|
||||
|
||||
def onFrameEnd(frame):
|
||||
"""Per-frame, AFTER network cooks. Use for capture, recording, post-network logic."""
|
||||
return
|
||||
|
||||
def onPlayStateChange(playing):
|
||||
"""Project play/pause toggled."""
|
||||
return
|
||||
|
||||
def onProjectPreSave():
|
||||
"""Right before saving the .toe file."""
|
||||
return
|
||||
|
||||
def onProjectPostSave():
|
||||
return
|
||||
```
|
||||
|
||||
Heavy per-frame logic in `onFrameStart` is one of the top performance regressions in TD projects. Use CHOPs for per-frame computation, scripts for events.
|
||||
|
||||
---
|
||||
|
||||
## Pattern: Triggering an Animation Sequence on Beat
|
||||
|
||||
```python
|
||||
# Source: a kick trigger CHOP
|
||||
# Goal: on each kick, run a 1.5s scale pulse + color flash
|
||||
|
||||
# Setup (create once)
|
||||
animator = root.create(timerCHOP, 'pulse_anim')
|
||||
animator.par.length = 1.5
|
||||
animator.par.cycle = False
|
||||
|
||||
# Param expressions on visual targets:
|
||||
op('logo').par.sx.expr = "1.0 + (1 - op('pulse_anim')['timer_fraction']) * 0.3"
|
||||
op('logo').par.sx.mode = ParMode.EXPRESSION
|
||||
op('logo').par.sy.expr = "1.0 + (1 - op('pulse_anim')['timer_fraction']) * 0.3"
|
||||
op('logo').par.sy.mode = ParMode.EXPRESSION
|
||||
|
||||
# In a chopExecuteDAT watching the kick CHOP:
|
||||
def offToOn(channel, sampleIndex, val, prev):
|
||||
op('pulse_anim').par.start.pulse()
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern: Live Editing a CHOP from API Data
|
||||
|
||||
```python
|
||||
# webDAT polls an API every 5 seconds
|
||||
# datExecuteDAT parses the response and writes to a constantCHOP
|
||||
|
||||
def onTableChange(dat):
|
||||
import json
|
||||
try:
|
||||
data = json.loads(dat.text)
|
||||
except:
|
||||
return
|
||||
target = op('/project1/external_state')
|
||||
target.par.name0 = 'temperature'
|
||||
target.par.value0 = float(data['temp_c'])
|
||||
target.par.name1 = 'humidity'
|
||||
target.par.value1 = float(data['humidity'])
|
||||
return
|
||||
```
|
||||
|
||||
Visuals just reference `op('external_state')['temperature']` — they update live.
|
||||
|
||||
---
|
||||
|
||||
## Pattern: Self-Cleaning Network
|
||||
|
||||
```python
|
||||
# An opExecuteDAT watching for orphaned helper ops, deleting them after their parent disappears
|
||||
|
||||
def onDestroy(opDestroyed):
|
||||
parent_name = opDestroyed.name
|
||||
helper = op(f'/project1/{parent_name}_helper')
|
||||
if helper:
|
||||
helper.destroy()
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Callbacks crash silently** — exceptions print to the textport but don't show up in the UI. Always `td_clear_textport` before debugging, then `td_read_textport` after.
|
||||
2. **`debug()` vs `print()`** — both write to textport, but `debug()` includes the file/line of the calling DAT. Prefer `debug()` for scripts.
|
||||
3. **`val` is the new value, `prev` is old** — easy to swap. Always: `def offToOn(channel, sampleIndex, val, prev)`. Check parameter order in TD docs if confused.
|
||||
4. **`whileOn` and `valueChange` are per-frame** — heavy. Avoid unless absolutely needed. Drive via expressions instead.
|
||||
5. **Callbacks don't run during cooking-paused state** — if the parent COMP has `allowCooking=False`, callbacks freeze. Useful for "disable me" toggles.
|
||||
6. **`par` vs `panelValue`** — parameterExecuteDAT gives `par` (a Par object), panelExecuteDAT gives `panelValue` (also a Par-like object). Both have `.name` and `.eval()` but their context differs.
|
||||
7. **`opExecuteDAT` fires for itself** — when you create an opExecuteDAT, it can fire `onCreate` for itself if `par.create=True` and parent matches. Filter by `if opCreated == me: return`.
|
||||
8. **Reload behavior** — when reloading an extension (`td_reinit_extension`), all callback DATs reset their internal state. Module-level vars are lost. Persist state in tableDATs or the docked DAT itself, not in module globals.
|
||||
9. **Cooking dependencies** — if a callback writes to an op that's upstream of the callback's source, you get a cooking loop. TD warns about it but doesn't always block. Keep dataflow one-directional.
|
||||
10. **Active flag** — every Execute DAT has `par.active`. False = silent. Easy to toggle for testing without deleting wiring.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Setup |
|
||||
|---|---|
|
||||
| Beat trigger | `chopExecuteDAT.par.offtoon=True` watching a `triggerCHOP` |
|
||||
| API response handler | `datExecuteDAT.par.tablechange=True` watching a `webDAT` |
|
||||
| Custom button → action | `parameterExecuteDAT.par.pulse=True` watching a custom pulse param |
|
||||
| Slider → continuous param | `panelExecuteDAT.par.value=True` watching a `sliderCOMP` |
|
||||
| Run-once setup | `executeDAT.par.start=True` with logic in `onStart()` |
|
||||
| Per-frame metrics | `executeDAT.par.frameend=True` recording values to a CHOP |
|
||||
| Auto-name new ops | `opExecuteDAT.par.create=True` enforcing naming conventions |
|
||||
@@ -1,322 +0,0 @@
|
||||
# External Data Reference
|
||||
|
||||
Network and device I/O — HTTP requests, WebSockets, MQTT, Serial, TCP, UDP. For MIDI/OSC specifically see `midi-osc.md`.
|
||||
|
||||
Common production needs:
|
||||
- API polling / webhook ingestion
|
||||
- Real-time data streams (sensors, market data, chat)
|
||||
- IoT device control (Arduino, ESP32, smart lights)
|
||||
- Inter-application messaging
|
||||
- Hosting a tiny TD-side HTTP server for remote control
|
||||
|
||||
---
|
||||
|
||||
## Web DAT — HTTP Requests
|
||||
|
||||
```python
|
||||
web = root.create(webDAT, 'api_call')
|
||||
web.par.url = 'https://api.example.com/v1/status'
|
||||
web.par.fetchmethod = 'get' # 'get' | 'post' | 'put' | 'delete'
|
||||
web.par.format = 'auto' # 'auto' | 'text' | 'json'
|
||||
web.par.timeout = 5.0
|
||||
```
|
||||
|
||||
**Triggering a request:**
|
||||
|
||||
`webDAT` does NOT auto-fetch on cook. Trigger explicitly:
|
||||
|
||||
```python
|
||||
web.par.fetch.pulse()
|
||||
```
|
||||
|
||||
Or via expression on a CHOP value-change (chopExecuteDAT — see `dat-scripting.md`).
|
||||
|
||||
**Authentication headers:**
|
||||
|
||||
Use `webclientDAT` (more flexible) or set `webDAT` headers via the headers DAT:
|
||||
|
||||
```python
|
||||
web_headers = root.create(tableDAT, 'headers')
|
||||
web_headers.appendRow(['Authorization', 'Bearer YOUR_TOKEN'])
|
||||
web_headers.appendRow(['Accept', 'application/json'])
|
||||
web.par.headers = web_headers.path
|
||||
```
|
||||
|
||||
**Parsing JSON response:**
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
def onTableChange(dat):
|
||||
response = dat.text # raw response body
|
||||
data = json.loads(response)
|
||||
# Update a tableDAT or store in a constantCHOP for downstream use
|
||||
op('/project1/api_status').par.value0 = data['count']
|
||||
return
|
||||
```
|
||||
|
||||
Wire this in a `datExecuteDAT` watching the webDAT.
|
||||
|
||||
**Polling pattern:**
|
||||
|
||||
```python
|
||||
# timerCHOP fires every N seconds
|
||||
timer = root.create(timerCHOP, 'poll_timer')
|
||||
timer.par.length = 5.0
|
||||
timer.par.cycle = True
|
||||
|
||||
# chopExecuteDAT on the timer's 'cycles' channel pulses the webDAT
|
||||
def offToOn(channel, sampleIndex, val, prev):
|
||||
op('/project1/api_call').par.fetch.pulse()
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web Client DAT — More Robust HTTP
|
||||
|
||||
`webclientDAT` is the modern replacement for `webDAT` — supports streaming responses, chunked transfer, custom auth.
|
||||
|
||||
```python
|
||||
client = root.create(webclientDAT, 'api')
|
||||
client.par.method = 'POST'
|
||||
client.par.url = 'https://api.example.com/events'
|
||||
client.par.uploadtype = 'json'
|
||||
client.par.uploaddata = '{"event": "scene_change", "scene": 3}'
|
||||
client.par.request.pulse()
|
||||
```
|
||||
|
||||
Output goes to its child `webclient1_response` DAT. Use a `datExecuteDAT` to react.
|
||||
|
||||
---
|
||||
|
||||
## Web Server DAT — TD as HTTP Server
|
||||
|
||||
Hosts a tiny HTTP server inside TD. Useful for:
|
||||
- Status/health endpoints
|
||||
- Remote control from a phone or another machine
|
||||
- Webhook receivers from external services
|
||||
|
||||
```python
|
||||
server = root.create(webserverDAT, 'control_server')
|
||||
server.par.port = 8080
|
||||
server.par.active = True
|
||||
|
||||
# Define handler in the docked callback DAT
|
||||
```
|
||||
|
||||
In the auto-created `webserver1_callbacks` DAT:
|
||||
|
||||
```python
|
||||
def onHTTPRequest(webServerDAT, request, response):
|
||||
path = request['uri']
|
||||
if path == '/status':
|
||||
response['statusCode'] = 200
|
||||
response['data'] = '{"fps": 60, "scene": "active"}'
|
||||
elif path == '/scene':
|
||||
idx = int(request['args'].get('index', 0))
|
||||
op('/project1/scene_switch').par.index = idx
|
||||
response['statusCode'] = 200
|
||||
response['data'] = 'OK'
|
||||
else:
|
||||
response['statusCode'] = 404
|
||||
response['data'] = 'Not Found'
|
||||
return response
|
||||
```
|
||||
|
||||
Test from terminal: `curl http://localhost:8080/status`.
|
||||
|
||||
**Security:** No auth by default. Bind to localhost only or add a token check in the callback. Never expose to the public internet without auth.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket DAT — Bidirectional Real-Time
|
||||
|
||||
For low-latency bidirectional streams (chat, live data feeds, controllers).
|
||||
|
||||
### Client
|
||||
|
||||
```python
|
||||
ws = root.create(websocketDAT, 'ws_client')
|
||||
ws.par.netaddress = 'wss://api.example.com/socket'
|
||||
ws.par.active = True
|
||||
```
|
||||
|
||||
In the docked callbacks DAT:
|
||||
|
||||
```python
|
||||
def onConnect(dat):
|
||||
dat.sendText('{"action": "subscribe", "channel": "ticks"}')
|
||||
return
|
||||
|
||||
def onReceiveText(dat, rowIndex, message):
|
||||
# message is a string; parse JSON, dispatch to ops
|
||||
import json
|
||||
data = json.loads(message)
|
||||
op('/project1/price_chop').par.value0 = data['price']
|
||||
return
|
||||
|
||||
def onDisconnect(dat):
|
||||
# Optionally schedule a reconnect
|
||||
return
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```python
|
||||
ws = root.create(websocketDAT, 'ws_server')
|
||||
ws.par.mode = 'server'
|
||||
ws.par.port = 9001
|
||||
ws.par.active = True
|
||||
```
|
||||
|
||||
Same callback structure with an additional `clientID` arg.
|
||||
|
||||
---
|
||||
|
||||
## MQTT — Pub/Sub for IoT
|
||||
|
||||
```python
|
||||
mqtt = root.create(mqttClientDAT, 'iot')
|
||||
mqtt.par.brokeraddress = 'broker.hivemq.com'
|
||||
mqtt.par.brokerport = 1883
|
||||
mqtt.par.clientid = 'td_install_01'
|
||||
mqtt.par.connect.pulse()
|
||||
|
||||
# Subscribe in callbacks DAT:
|
||||
def onConnect(dat):
|
||||
dat.subscribe('home/lights/+', qos=1)
|
||||
return
|
||||
|
||||
def onReceive(dat, topic, payload, qos, retained, dup):
|
||||
# payload is bytes — decode if JSON
|
||||
msg = payload.decode('utf-8')
|
||||
# Dispatch by topic
|
||||
return
|
||||
|
||||
# Publish from anywhere:
|
||||
op('iot').publish('show/scene', 'sunset', qos=0, retain=False)
|
||||
```
|
||||
|
||||
For Mosquitto / HiveMQ self-hosted brokers use the same setup with `tcp://192.168.x.x` and your local port.
|
||||
|
||||
---
|
||||
|
||||
## Serial DAT — Arduino, USB Devices
|
||||
|
||||
```python
|
||||
serial = root.create(serialDAT, 'arduino')
|
||||
serial.par.port = '/dev/cu.usbmodem14101' # macOS — check Arduino IDE
|
||||
# Windows: 'COM3', 'COM4', etc.
|
||||
serial.par.baudrate = 115200
|
||||
serial.par.active = True
|
||||
```
|
||||
|
||||
In callbacks:
|
||||
|
||||
```python
|
||||
def onReceive(dat, rowIndex, line):
|
||||
# Each newline-terminated line from Arduino arrives here
|
||||
parts = line.split(',')
|
||||
op('/project1/sensors').par.value0 = float(parts[0])
|
||||
op('/project1/sensors').par.value1 = float(parts[1])
|
||||
return
|
||||
```
|
||||
|
||||
Send to Arduino:
|
||||
```python
|
||||
op('arduino').send('LED_ON\n')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TCP/IP DAT — Custom Protocols
|
||||
|
||||
For talking to non-HTTP servers (game servers, custom protocols, legacy systems).
|
||||
|
||||
```python
|
||||
tcp = root.create(tcpipDAT, 'show_control')
|
||||
tcp.par.netaddress = '192.168.1.50'
|
||||
tcp.par.port = 7000
|
||||
tcp.par.protocol = 'tcp' # 'tcp' | 'udp'
|
||||
tcp.par.active = True
|
||||
```
|
||||
|
||||
Send / receive via callbacks similar to websocketDAT.
|
||||
|
||||
For UDP-only (fire-and-forget, no connection), use `udpoutDAT` + `udpinDAT` — simpler but unreliable across networks.
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### REST API → Visual
|
||||
|
||||
```
|
||||
timerCHOP (5s loop)
|
||||
→ chopExecuteDAT (pulse webDAT.par.fetch on cycle)
|
||||
→ webDAT (returns JSON)
|
||||
→ datExecuteDAT (parse, write to constantCHOP)
|
||||
→ CHOP drives glsl uniform → visuals
|
||||
```
|
||||
|
||||
### Webhook receiver
|
||||
|
||||
```
|
||||
webserverDAT (port 8080, /webhook endpoint)
|
||||
→ callback writes to a tableDAT log + triggers a scene change
|
||||
```
|
||||
|
||||
### Real-time stock/crypto ticker
|
||||
|
||||
```
|
||||
websocketDAT (subscribe to feed)
|
||||
→ onReceiveText callback parses JSON
|
||||
→ writes to constantCHOP
|
||||
→ drives bar chart / typography animation
|
||||
```
|
||||
|
||||
### IoT-controlled installation
|
||||
|
||||
```
|
||||
MQTT → callback dispatches by topic
|
||||
→ /lights/main → constantCHOP drives lighting render
|
||||
→ /audio/volume → mathCHOP for master fader
|
||||
```
|
||||
|
||||
### Two-way phone control
|
||||
|
||||
```
|
||||
WebSocket server in TD
|
||||
→ simple HTML page on phone connects, sends slider values
|
||||
→ callback writes to ops
|
||||
→ TD pushes status back via dat.sendText() to phone UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **`webDAT` doesn't auto-fetch** — must explicitly pulse `par.fetch`. Easy to forget.
|
||||
2. **Blocking on slow APIs** — `webDAT` runs on the cook thread. A 30s API call freezes TD for 30s. Use `webclientDAT` (async) for anything potentially slow.
|
||||
3. **WebSocket reconnection** — TD does NOT auto-reconnect on disconnect. Implement backoff in `onDisconnect`.
|
||||
4. **Serial port permissions on macOS** — TD needs Full Disk Access OR the port needs to be unlocked via `sudo chmod 666 /dev/cu.usbmodem...` per session.
|
||||
5. **MQTT broker connection state** — `mqttClientDAT` may show `connected=true` but messages don't flow if QoS is wrong or topic ACL blocks. Check broker logs.
|
||||
6. **JSON parse errors crash callbacks silently** — wrap parses in try/except and log to textport. Otherwise the callback just stops firing.
|
||||
7. **Firewall on Windows** — first time `webserverDAT` binds, Windows pops a firewall dialog. Approve it or the server is unreachable.
|
||||
8. **CORS** — `webserverDAT` doesn't add CORS headers by default. If serving a webapp from a different origin, add `Access-Control-Allow-Origin: *` in the response.
|
||||
9. **Polling vs push** — polling burns API quota. Always prefer WebSocket / webhook / MQTT for high-frequency data.
|
||||
10. **Floating-point parsing** — sensor data over Serial often comes as strings. `float()` will crash on `'\n'` or `'NaN'`. Validate before converting.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Op chain |
|
||||
|---|---|
|
||||
| Periodic API fetch | `timerCHOP` → `chopExecuteDAT` pulses → `webDAT` → `datExecuteDAT` parses |
|
||||
| Webhook receiver | `webserverDAT` (port + path), callback writes to ops |
|
||||
| Real-time stream | `websocketDAT` client → onReceiveText → CHOP/DAT |
|
||||
| Arduino sensor → visual | `serialDAT` → callback → `constantCHOP` → expression on visual op |
|
||||
| TD ↔ phone control | `websocketDAT` server + simple HTML page on phone |
|
||||
| MQTT IoT integration | `mqttClientDAT` subscribe → callback dispatches by topic |
|
||||
@@ -1,211 +0,0 @@
|
||||
# MIDI / OSC Reference
|
||||
|
||||
External controller input and output — MIDI hardware, TouchOSC mobile UIs, OSC routing across the network.
|
||||
|
||||
For audio-driven MIDI patterns (track triggers from spectrum analysis), see also `audio-reactive.md`.
|
||||
|
||||
---
|
||||
|
||||
## MIDI Input — Hardware Controllers
|
||||
|
||||
### Discovery
|
||||
|
||||
List connected MIDI devices first. Use a `midiinDAT` to enumerate:
|
||||
|
||||
```python
|
||||
mdat = root.create(midiinDAT, 'mid_devices')
|
||||
# Read available device names from the DAT after one cook
|
||||
```
|
||||
|
||||
Or via Python directly:
|
||||
|
||||
```python
|
||||
# In td_execute_python
|
||||
import td
|
||||
devices = [d for d in op.MIDI.devices] # verify with td_get_docs('midi')
|
||||
```
|
||||
|
||||
Verify the API with `td_get_docs(topic='midi')` since this varies between TD versions.
|
||||
|
||||
### MIDI In CHOP
|
||||
|
||||
Standard pattern:
|
||||
|
||||
```python
|
||||
midi_in = root.create(midiinCHOP, 'midi_in')
|
||||
midi_in.par.device = 0 # device index from discovery
|
||||
midi_in.par.activechan = True
|
||||
```
|
||||
|
||||
Output channels follow the convention `chCcN` and `chCnN`:
|
||||
- `ch1c74` — channel 1, CC 74
|
||||
- `ch1n60` — channel 1, note 60 (middle C) — value is velocity 0-127
|
||||
|
||||
**Map a CC to a parameter:**
|
||||
|
||||
```python
|
||||
op('/project1/bloom1').par.threshold.mode = ParMode.EXPRESSION
|
||||
op('/project1/bloom1').par.threshold.expr = "op('midi_in')['ch1c74'][0] / 127.0"
|
||||
```
|
||||
|
||||
**Map a note as a trigger:**
|
||||
|
||||
Notes in `midiinCHOP` output velocity while held, 0 when released. Use a `triggerCHOP` to convert a held note into pulses:
|
||||
|
||||
```python
|
||||
trig = root.create(triggerCHOP, 'note_trig')
|
||||
trig.par.threshold = 1
|
||||
trig.par.triggeron = 'increase'
|
||||
trig.inputConnectors[0].connect(op('midi_in'))
|
||||
# Filter to a single channel via a selectCHOP if desired
|
||||
```
|
||||
|
||||
### MIDI Learn Pattern
|
||||
|
||||
Build a reusable learn pattern when you don't know the controller's CC layout in advance:
|
||||
|
||||
1. Drop a `midiinCHOP` and `selectCHOP` after it.
|
||||
2. User wiggles the controller knob.
|
||||
3. Use `td_read_chop` on the midiinCHOP to identify which channel is non-zero — that's the active CC.
|
||||
4. Set the `selectCHOP.par.channames` to that channel name.
|
||||
5. Save the mapping to a `tableDAT` so it persists across sessions.
|
||||
|
||||
---
|
||||
|
||||
## MIDI Output
|
||||
|
||||
```python
|
||||
midi_out = root.create(midioutCHOP, 'midi_out')
|
||||
midi_out.par.device = 0
|
||||
midi_out.par.outputformat = 'continuous' # 'continuous' | 'event'
|
||||
|
||||
# Drive an output: send out a CC mapped from any 0-1 source
|
||||
src = root.create(constantCHOP, 'cc_src')
|
||||
src.par.name0 = 'ch1c20'
|
||||
src.par.value0 = 0.5
|
||||
midi_out.inputConnectors[0].connect(src)
|
||||
```
|
||||
|
||||
For note events specifically, use `event` mode and pulse the value with a `pulseCHOP` or `triggerCHOP`.
|
||||
|
||||
---
|
||||
|
||||
## OSC Input — Network Control
|
||||
|
||||
OSC is the more flexible cousin of MIDI. Used heavily for:
|
||||
- TouchOSC / Lemur mobile control surfaces
|
||||
- Show control systems (QLab, Watchout)
|
||||
- Inter-application sync (Ableton via Max for Live, Resolume, etc.)
|
||||
|
||||
### OSC In CHOP
|
||||
|
||||
```python
|
||||
osc_in = root.create(oscinCHOP, 'osc_in')
|
||||
osc_in.par.port = 7000 # listen on UDP 7000
|
||||
osc_in.par.localaddress = '' # empty = all interfaces
|
||||
osc_in.par.queued = False # immediate vs. queued processing
|
||||
```
|
||||
|
||||
Each incoming OSC address becomes a channel. `/scene/1/intensity` becomes a channel named `scene_1_intensity` (TD sanitizes slashes to underscores).
|
||||
|
||||
**Common gotcha:** TD only creates the channel after the FIRST message arrives at that address. Send a "hello" message from the controller during setup, or pre-declare channel names manually.
|
||||
|
||||
### OSC In DAT (for raw events)
|
||||
|
||||
Use a `oscinDAT` when you need full message access (multiple typed args, addresses with brackets/regex).
|
||||
|
||||
```python
|
||||
osc_dat = root.create(oscinDAT, 'osc_events')
|
||||
osc_dat.par.port = 7001
|
||||
# Each row: timestamp, address, type tags, args...
|
||||
```
|
||||
|
||||
Drive logic via a `datExecuteDAT` watching the `oscinDAT`:
|
||||
|
||||
```python
|
||||
def onTableChange(dat):
|
||||
last = dat[dat.numRows - 1, 'message']
|
||||
parsed = last.val.split()
|
||||
addr = parsed[0]
|
||||
args = parsed[1:]
|
||||
if addr == '/scene/trigger':
|
||||
op('/project1/scene_switcher').par.index = int(args[0])
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OSC Output — Sending to External Apps
|
||||
|
||||
```python
|
||||
osc_out = root.create(oscoutCHOP, 'osc_out')
|
||||
osc_out.par.netaddress = '127.0.0.1' # destination IP
|
||||
osc_out.par.port = 9000
|
||||
|
||||
# Channel names become OSC addresses
|
||||
src = root.create(constantCHOP, 'send')
|
||||
src.par.name0 = 'scene/intensity' # → /scene/intensity
|
||||
src.par.value0 = 0.7
|
||||
osc_out.inputConnectors[0].connect(src)
|
||||
```
|
||||
|
||||
**Channel-to-address mapping:** TD prepends `/` automatically. Use `/` in channel names to nest.
|
||||
|
||||
For one-shot string/typed messages, use `oscoutDAT` and call `.sendOSC(address, args)`:
|
||||
|
||||
```python
|
||||
op('osc_out_dat').sendOSC('/scene/trigger', [1, 'fade'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TouchOSC / Mobile UI Pattern
|
||||
|
||||
Common setup for live VJ control from a phone/tablet:
|
||||
|
||||
1. **Configure TouchOSC layout** — assign each control an OSC address like `/vj/master`, `/vj/scene/1`, etc.
|
||||
2. **Find your machine's LAN IP** — TouchOSC needs to point at it.
|
||||
3. **TD listens** on `oscinCHOP.par.port = 8000` (or whichever).
|
||||
4. **Map channels to params** via expressions:
|
||||
|
||||
```python
|
||||
op('/project1/master_level').par.opacity.mode = ParMode.EXPRESSION
|
||||
op('/project1/master_level').par.opacity.expr = "op('osc_in')['vj_master']"
|
||||
```
|
||||
|
||||
5. **Send feedback** to the controller via `oscoutCHOP` — useful for syncing state across multiple devices.
|
||||
|
||||
---
|
||||
|
||||
## Network / Multi-Machine
|
||||
|
||||
OSC over LAN works out-of-the-box. For multi-TD-instance sync (e.g., projection cluster):
|
||||
|
||||
- One TD acts as **master**, broadcasts `/sync/...` over OSC
|
||||
- Worker TDs run `oscinCHOP` listening on the same port
|
||||
- Use UDP **broadcast address** (e.g., `192.168.1.255`) on the master's `oscoutCHOP.par.netaddress` to hit all peers
|
||||
|
||||
For reliability over WAN, use `webserverDAT` or `websocketDAT` with an external relay instead — UDP loss is invisible.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **MIDI device indexing** — device `0` is whichever device TD enumerated first. Reorder may shift it. Pin by name when possible.
|
||||
2. **OSC channel names** — TD doesn't create a channel until the first message lands. New channels invalidate cooked dependents on first arrival, causing a one-frame stutter.
|
||||
3. **OSC queued mode** — `par.queued = True` defers processing to a single per-frame batch. Lower latency but messages arriving same frame collapse to the last value. Off for triggers, on for continuous knobs.
|
||||
4. **MIDI clock vs. transport** — `midiinCHOP` reports clock if available. Use `midisyncCHOP` (if your TD version exposes it) or compute BPM from clock pulses (24 per quarter note).
|
||||
5. **Latency** — wired MIDI is ~1-3ms. WiFi OSC is 10-30ms with jitter. Use wired for tight beat-locked work.
|
||||
6. **Port conflicts** — only one process can bind a UDP port on most OS. If `oscinCHOP` shows no traffic, check that another app (Max, Ableton, etc.) isn't already listening on that port.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Op chain |
|
||||
|---|---|
|
||||
| Knob → bloom intensity | `midiinCHOP` → expression on `bloom.par.threshold` |
|
||||
| Note → scene change | `midiinCHOP` → `triggerCHOP` → `selectCHOP` → drive `switchTOP.par.index` |
|
||||
| Phone slider → master fader | TouchOSC `/master` → `oscinCHOP` → expression on output `level.par.opacity` |
|
||||
| TD → Resolume scene trigger | `oscoutCHOP` channel `composition/layers/1/clips/1/connect` → Resolume listening on 7000 |
|
||||
| Multi-projector sync | Master TD `oscoutCHOP` broadcast → workers `oscinCHOP` |
|
||||
@@ -1,281 +0,0 @@
|
||||
# Panel & UI Reference
|
||||
|
||||
Interactive control surfaces inside TouchDesigner — buttons, sliders, fields, custom parameter pages, panel callbacks. For HUD overlays (rendered text on visuals) see `layout-compositor.md`.
|
||||
|
||||
Use cases:
|
||||
- VJ control rack (master fader, scene buttons, FX toggles)
|
||||
- Installation operator console
|
||||
- Self-contained TOX components with their own parameter UIs
|
||||
- Phone-style touch interfaces displayed on a tablet
|
||||
|
||||
---
|
||||
|
||||
## Two Layers of UI
|
||||
|
||||
| Layer | What it is | Use for |
|
||||
|---|---|---|
|
||||
| **Custom Parameters** | Params on any COMP, edited like built-in TD params | Configurable components, presets, "settings" panels |
|
||||
| **Panel COMPs** | Visible widgets (button, slider, field) inside a containerCOMP | Interactive control surfaces, real-time UIs |
|
||||
|
||||
Combine both: build a containerCOMP with panel widgets that read/write custom parameters on a parent component.
|
||||
|
||||
---
|
||||
|
||||
## Custom Parameters
|
||||
|
||||
Add user-editable params to any COMP. Params persist with the COMP, drive expressions, and survive save/reload.
|
||||
|
||||
```python
|
||||
# Add a custom page to a baseCOMP
|
||||
comp = op('/project1/my_component')
|
||||
page = comp.appendCustomPage('Controls')
|
||||
|
||||
# Add typed params
|
||||
page.appendFloat('Intensity', label='Intensity')[0] # returns a Par
|
||||
page.appendInt('Count', label='Count')[0]
|
||||
page.appendToggle('Enabled', label='Enabled')[0]
|
||||
page.appendMenu('Mode', menuNames=['off', 'soft', 'hard'], menuLabels=['Off', 'Soft', 'Hard'])[0]
|
||||
page.appendStr('Title', label='Title')[0]
|
||||
page.appendRGB('Color', label='Color') # returns 3 pars
|
||||
page.appendXY('Offset', label='Offset') # returns 2 pars
|
||||
page.appendPulse('Reset', label='Reset')[0]
|
||||
page.appendFile('TextureFile', label='Texture')[0]
|
||||
```
|
||||
|
||||
**Read/write from anywhere:**
|
||||
|
||||
```python
|
||||
val = op('/project1/my_component').par.Intensity.eval()
|
||||
op('/project1/my_component').par.Intensity = 0.7
|
||||
```
|
||||
|
||||
**Drive other params via expression:**
|
||||
|
||||
```python
|
||||
op('bloom1').par.threshold.mode = ParMode.EXPRESSION
|
||||
op('bloom1').par.threshold.expr = "op('/project1/my_component').par.Intensity"
|
||||
```
|
||||
|
||||
**Pulse handler (Reset button):**
|
||||
|
||||
Use a `parameterExecuteDAT` watching the COMP's pulse params. See `dat-scripting.md`.
|
||||
|
||||
---
|
||||
|
||||
## Panel COMPs — The Widgets
|
||||
|
||||
Each is a COMP that renders as a clickable/draggable widget inside a `containerCOMP`.
|
||||
|
||||
| Type | Type Name | Use |
|
||||
|---|---|---|
|
||||
| Button | `buttonCOMP` | Click action — momentary or toggle |
|
||||
| Slider | `sliderCOMP` | Drag to set 0-1 value (1D or 2D) |
|
||||
| Field | `fieldCOMP` | Text input |
|
||||
| Container | `containerCOMP` | Layout + visual styling, holds children |
|
||||
| Select | `selectCOMP` | Reference and display content from another COMP |
|
||||
| List | `listCOMP` | Scrollable list with row callbacks |
|
||||
|
||||
### Button
|
||||
|
||||
```python
|
||||
btn = root.create(buttonCOMP, 'play_btn')
|
||||
btn.par.w = 120; btn.par.h = 40
|
||||
btn.par.buttontype = 'momentary' # 'momentary' | 'toggleup' | 'togglepress' | 'radio'
|
||||
btn.par.bgcolorr = 0.1; btn.par.bgcolorg = 0.1; btn.par.bgcolorb = 0.1
|
||||
btn.par.text = 'Play'
|
||||
|
||||
# Read state
|
||||
state = btn.panel.state # 1 when active
|
||||
```
|
||||
|
||||
### Slider
|
||||
|
||||
```python
|
||||
sld = root.create(sliderCOMP, 'master_fader')
|
||||
sld.par.w = 60; sld.par.h = 300
|
||||
sld.par.style = 'vertical' # 'vertical' | 'horizontal' | 'xy'
|
||||
sld.par.value0min = 0.0
|
||||
sld.par.value0max = 1.0
|
||||
|
||||
# Drive a parameter via expression (always-on, no callback needed)
|
||||
op('/project1/master_level').par.opacity.mode = ParMode.EXPRESSION
|
||||
op('/project1/master_level').par.opacity.expr = "op('master_fader').panel.u"
|
||||
```
|
||||
|
||||
`panel.u` and `panel.v` give the 0-1 normalized values. For 2D sliders both are populated.
|
||||
|
||||
### Field (Text Input)
|
||||
|
||||
```python
|
||||
fld = root.create(fieldCOMP, 'scene_name')
|
||||
fld.par.w = 200; fld.par.h = 30
|
||||
fld.par.fieldtype = 'string' # 'string' | 'integer' | 'float'
|
||||
|
||||
# Read current text
|
||||
text = fld.panel.field # the text content
|
||||
```
|
||||
|
||||
### List
|
||||
|
||||
For scrollable lists with selectable rows, use the docked `list1_callbacks` DAT to handle row interactions. Set up cells via the `list_definition` table DAT.
|
||||
|
||||
---
|
||||
|
||||
## Container COMP — Layout & Styling
|
||||
|
||||
`containerCOMP` is the primary parent for grouping widgets and arranging layouts.
|
||||
|
||||
```python
|
||||
panel = root.create(containerCOMP, 'control_panel')
|
||||
panel.par.w = 400; panel.par.h = 600
|
||||
panel.par.bgcolorr = 0.05
|
||||
panel.par.bgcolorg = 0.05
|
||||
panel.par.bgcolorb = 0.05
|
||||
panel.par.bgalpha = 1.0
|
||||
|
||||
# Layout child panels in vertical stack
|
||||
panel.par.align = 'lefttoright' # 'lefttoright' | 'toptobottom' | etc.
|
||||
```
|
||||
|
||||
Children are positioned automatically based on `par.align`. For absolute positioning use `par.align = 'fillresize'` and set each child's `par.x` / `par.y`.
|
||||
|
||||
### Layout Strategies
|
||||
|
||||
| `par.align` | Behavior |
|
||||
|---|---|
|
||||
| `lefttoright` | Children stacked horizontally |
|
||||
| `toptobottom` | Children stacked vertically |
|
||||
| `righttoleft` / `bottomtotop` | Reversed stacks |
|
||||
| `fillresize` | Children sized to fill, manual positioning |
|
||||
| `top` / `bottom` / `left` / `right` | Fixed positioning |
|
||||
|
||||
For complex grids: nest containers — vertical container holding horizontal containers.
|
||||
|
||||
---
|
||||
|
||||
## Panel Callbacks — Reacting to Events
|
||||
|
||||
`panelExecuteDAT` watches a panel and fires Python callbacks on user interaction.
|
||||
|
||||
```python
|
||||
pe = root.create(panelExecuteDAT, 'btn_handler')
|
||||
pe.par.panel = '/project1/play_btn'
|
||||
pe.par.click = True # respond to clicks
|
||||
pe.par.value = True # respond to value changes
|
||||
```
|
||||
|
||||
In its docked DAT:
|
||||
|
||||
```python
|
||||
def onOffToOn(panelValue):
|
||||
# Click pressed
|
||||
op('/project1/scene_timer').par.start.pulse()
|
||||
return
|
||||
|
||||
def onOnToOff(panelValue):
|
||||
# Click released
|
||||
return
|
||||
|
||||
def onValueChange(panelValue):
|
||||
# Slider drag, field change, etc.
|
||||
new_val = panelValue.eval()
|
||||
op('/project1/master').par.opacity = new_val
|
||||
return
|
||||
```
|
||||
|
||||
For pulse params on custom-parameter pages, use a `parameterExecuteDAT` instead.
|
||||
|
||||
---
|
||||
|
||||
## Building a Complete VJ Control Panel
|
||||
|
||||
End-to-end pattern:
|
||||
|
||||
```python
|
||||
# 1. Top-level container
|
||||
panel = root.create(containerCOMP, 'vj_control')
|
||||
panel.par.w = 800; panel.par.h = 200
|
||||
panel.par.align = 'lefttoright'
|
||||
|
||||
# 2. Master fader column
|
||||
master_col = panel.create(containerCOMP, 'master')
|
||||
master_col.par.w = 120; master_col.par.h = 200
|
||||
master_col.par.align = 'toptobottom'
|
||||
|
||||
master_label = master_col.create(textTOP, 'lbl')
|
||||
master_label.par.text = 'MASTER'
|
||||
|
||||
master_sld = master_col.create(sliderCOMP, 'fader')
|
||||
master_sld.par.w = 60; master_sld.par.h = 150
|
||||
master_sld.par.style = 'vertical'
|
||||
|
||||
# 3. Scene buttons row
|
||||
scene_col = panel.create(containerCOMP, 'scenes')
|
||||
scene_col.par.w = 400; scene_col.par.h = 200
|
||||
scene_col.par.align = 'lefttoright'
|
||||
for i in range(8):
|
||||
b = scene_col.create(buttonCOMP, f'scene_{i+1}')
|
||||
b.par.w = 50; b.par.h = 50
|
||||
b.par.text = str(i+1)
|
||||
b.par.buttontype = 'radio' # only one active at a time
|
||||
|
||||
# 4. FX toggle column
|
||||
fx_col = panel.create(containerCOMP, 'fx')
|
||||
fx_col.par.w = 280; fx_col.par.h = 200
|
||||
fx_col.par.align = 'toptobottom'
|
||||
for fx in ['Bloom', 'CRT', 'Glitch', 'Strobe']:
|
||||
t = fx_col.create(buttonCOMP, fx.lower())
|
||||
t.par.w = 220; t.par.h = 35
|
||||
t.par.text = fx
|
||||
t.par.buttontype = 'toggleup'
|
||||
|
||||
# 5. Display in a window
|
||||
win = root.create(windowCOMP, 'control_win')
|
||||
win.par.winop = panel.path
|
||||
win.par.winw = 800; win.par.winh = 200
|
||||
win.par.borders = True
|
||||
win.par.winopen.pulse()
|
||||
```
|
||||
|
||||
Then wire panel values to ops via expressions or panelExecuteDATs.
|
||||
|
||||
---
|
||||
|
||||
## Showing the Panel — Window or Embedded
|
||||
|
||||
| Approach | When |
|
||||
|---|---|
|
||||
| `windowCOMP` pointing at panel | Standalone control surface, separate display |
|
||||
| Render the containerCOMP via `renderTOP` | Composite UI over visuals (HUD-style) |
|
||||
| Use a `panelCOMP` directly inside a network editor pane | Designer/dev preview only — panel is fully interactive |
|
||||
|
||||
For a touch-screen tablet, use a `windowCOMP` on a second display routed to the tablet's HDMI input.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Panel won't respond to clicks** — likely `par.disabled = True` or the parent container has `par.disableinputs = True`. Check the panel hierarchy.
|
||||
2. **Slider value not updating** — `panel.u/v` reads the visual position. If you set `par.value0` directly, the visual lags. Use `par.value0` AS the source of truth and let the slider follow.
|
||||
3. **Custom param won't appear** — must call `appendCustomPage` first, then append params. Pages with no params don't show.
|
||||
4. **Custom param disappears on reload** — params added via Python at runtime persist only if the COMP is saved AFTER. Use a `tox` save (`comp.save('mycomp.tox')`) or commit via `td_execute_python` then save the project.
|
||||
5. **Event callback fires twice** — both `onOffToOn` and `onValueChange` may fire on a single button press. Pick one to handle the action; don't double-trigger.
|
||||
6. **Pulse params need `.pulse()`** — setting `par.X = True` on a pulse param does nothing. Always use `.pulse()`.
|
||||
7. **Field text doesn't commit until Tab/Enter** — fields don't fire callbacks while typing. Use `par.committemode = 'all'` to fire on every keystroke (heavy).
|
||||
8. **`par.text` vs panel content** — `buttonCOMP.par.text` is the LABEL on the button. The button's STATE is `panel.state` (0/1). Don't confuse them.
|
||||
9. **Touch input on macOS** — multi-touch via direct touch panels works but TD's gesture handling is rudimentary. For complex multi-touch (pinch/rotate), use TouchOSC on a tablet instead.
|
||||
10. **Layout doesn't update** — changing `par.align` requires the container to re-cook. Touch a child or pulse the container to trigger.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Setup |
|
||||
|---|---|
|
||||
| Master fader | `sliderCOMP` (vertical) → expression on `level.par.opacity` |
|
||||
| Scene picker | 8 `buttonCOMP` (radio) → `selectCHOP` on their state → drive `switchTOP.par.index` |
|
||||
| FX toggle | `buttonCOMP` (toggleup) → expression on `bypass` of an FX op |
|
||||
| Numeric input | `fieldCOMP` (float) → expression on target par |
|
||||
| Component settings | Custom params on the component COMP, panel widgets inside drive them |
|
||||
| Touch tablet UI | `containerCOMP` with widgets → `windowCOMP` to second display |
|
||||
| Status display | `textTOP` rendered into the panel via `selectCOMP` |
|
||||
@@ -1,245 +0,0 @@
|
||||
# Particles Reference
|
||||
|
||||
Particle systems in TouchDesigner — modern POPs (Particle Operators) and the legacy particleSOP path.
|
||||
|
||||
For instancing static geometry (without per-instance lifetime/velocity), see `geometry-comp.md`. For GLSL-driven feedback simulations (no particle abstraction), see `operator-tips.md` (Feedback TOP section).
|
||||
|
||||
Always call `td_get_par_info` for the op type before setting params. Param names below reflect TD 2025.32 — verify before relying on them.
|
||||
|
||||
---
|
||||
|
||||
## Two Paths: POPs vs. SOPs
|
||||
|
||||
| | **POP family** (modern) | **particleSOP** (legacy) |
|
||||
|---|---|---|
|
||||
| GPU? | Yes (compute) | No (CPU) |
|
||||
| Particle count | 100k+ comfortably | ~5k before slowdown |
|
||||
| API style | Source / Force / Solver / Render chain | Single op with many params |
|
||||
| Use for | New projects, anything intensive | Quick demos, low counts, TD < 2023 |
|
||||
|
||||
**Default to POPs.** Only fall back to particleSOP if a POP variant of an op you need doesn't exist.
|
||||
|
||||
---
|
||||
|
||||
## POP Pipeline Overview
|
||||
|
||||
A POP system is a chain of operators inside a `geometryCOMP`:
|
||||
|
||||
```
|
||||
popSourceTOP / popSourceSOP ← spawn new particles
|
||||
↓
|
||||
popForceTOP (gravity, wind, etc.)
|
||||
↓
|
||||
popForceTOP (attractor, vortex, ...)
|
||||
↓
|
||||
popDeleteTOP (lifetime, bounds)
|
||||
↓
|
||||
popSolverTOP ← integrates velocity, updates positions
|
||||
↓
|
||||
[render via geometryCOMP / glslMAT instancing]
|
||||
```
|
||||
|
||||
POP buffers carry standard channels: `P` (position), `v` (velocity), `life`, `id`, `Cd` (color), plus any custom channels you add.
|
||||
|
||||
---
|
||||
|
||||
## Minimal POP Setup
|
||||
|
||||
```python
|
||||
# Create a geometry COMP to hold the POP network
|
||||
geo = root.create(geometryCOMP, 'particles_geo')
|
||||
|
||||
# 1. Source — emit particles from a point
|
||||
src = geo.create(popSourceTOP, 'src')
|
||||
src.par.birthrate = 500 # per second
|
||||
src.par.life = 4.0 # seconds
|
||||
|
||||
# 2. Gravity force
|
||||
grav = geo.create(popForceTOP, 'gravity')
|
||||
grav.par.forcetype = 'gravity'
|
||||
grav.par.fy = -9.8
|
||||
|
||||
# 3. Lifetime cleanup
|
||||
delp = geo.create(popDeleteTOP, 'cull')
|
||||
delp.par.condition = 'lifeleq' # delete when life <= 0
|
||||
delp.par.value = 0
|
||||
|
||||
# 4. Solver
|
||||
solv = geo.create(popSolverTOP, 'solver')
|
||||
solv.par.timestep = 'frame'
|
||||
|
||||
# Wire: source → force → delete → solver
|
||||
src.outputConnectors[0].connect(grav.inputConnectors[0])
|
||||
grav.outputConnectors[0].connect(delp.inputConnectors[0])
|
||||
delp.outputConnectors[0].connect(solv.inputConnectors[0])
|
||||
```
|
||||
|
||||
The `popSolverTOP` output IS the live particle buffer. Render it via `glslMAT` instancing on a small SOP (sphere, point) as the "shape" of each particle.
|
||||
|
||||
---
|
||||
|
||||
## Common Forces
|
||||
|
||||
| Force type | Effect | Common params |
|
||||
|---|---|---|
|
||||
| `gravity` | Constant directional pull | `fx`, `fy`, `fz` |
|
||||
| `wind` | Constant velocity addition | `wx`, `wy`, `wz` |
|
||||
| `drag` | Velocity damping over time | `dragstrength` |
|
||||
| `noise` | Curl-noise turbulence | `noiseamp`, `noisefreq`, `noiseseed` |
|
||||
| `attractor` | Pull toward a point | `position`, `strength`, `falloff` |
|
||||
| `vortex` | Swirl around an axis | `axis`, `strength` |
|
||||
| `point` (custom) | GLSL-evaluated arbitrary force | via `popforceadvancedTOP` |
|
||||
|
||||
Stack multiple `popForceTOP`s in series — each modifies velocity additively.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Patterns
|
||||
|
||||
### Continuous emission (e.g. smoke plume)
|
||||
|
||||
```python
|
||||
src.par.birthrate = 800
|
||||
src.par.life = 6.0 # variance via 'lifevariance'
|
||||
src.par.lifevariance = 1.5
|
||||
```
|
||||
|
||||
### Burst emission (e.g. explosion)
|
||||
|
||||
```python
|
||||
src.par.birthrate = 0 # no continuous emission
|
||||
src.par.burst.pulse() # one burst on demand (verify param name)
|
||||
src.par.burstcount = 5000
|
||||
src.par.life = 1.5
|
||||
```
|
||||
|
||||
### Beat-triggered burst
|
||||
|
||||
Wire a `triggerCHOP` (from audio or MIDI) to pulse the burst:
|
||||
|
||||
```python
|
||||
op('/project1/audio_kick_trigger').outputConnectors[0].connect(...)
|
||||
# Then via a chopExecuteDAT, on each kick:
|
||||
def offToOn(channel, sampleIndex, val, prev):
|
||||
op('/project1/particles_geo/src').par.burst.pulse()
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rendering Particles
|
||||
|
||||
### Point Sprites (simplest)
|
||||
|
||||
```python
|
||||
# Inside the geometryCOMP, render the solver output directly
|
||||
# The geo's first SOP child becomes the geometry
|
||||
# But for POPs, we typically render via glslMAT on a small "shape"
|
||||
|
||||
# Simple billboard sphere per particle:
|
||||
shape = geo.create(sphereSOP, 'shape')
|
||||
shape.par.rad = 0.05
|
||||
shape.par.rows = 6; shape.par.cols = 6 # low-poly to keep it fast
|
||||
|
||||
# Material that uses POP buffer for instancing
|
||||
mat = root.create(glslMAT, 'particle_mat')
|
||||
# Configure mat.par.instancingTOP = solver output (verify param name)
|
||||
```
|
||||
|
||||
The exact instancing setup varies by TD version — call `td_get_hints(topic='popInstancing')` (or `popRender` / `instancing` — try a few).
|
||||
|
||||
### GPU Sprites via glslcopyPOP
|
||||
|
||||
For dense smoke/fire-like effects, use a `glslcopyPOP` that writes per-particle color/size from a compute shader, then render as point sprites with additive blending in a `renderTOP`.
|
||||
|
||||
---
|
||||
|
||||
## Collisions
|
||||
|
||||
```python
|
||||
# Collision detection against an SOP
|
||||
coll = geo.create(popCollideTOP, 'ground_coll')
|
||||
coll.par.collidewithsop = '/project1/ground_geo' # path to colliding SOP
|
||||
coll.par.bounce = 0.3
|
||||
coll.par.friction = 0.1
|
||||
# Insert between force and solver
|
||||
```
|
||||
|
||||
For plane/box collisions only, use `popPlaneCollideTOP` (cheaper).
|
||||
|
||||
---
|
||||
|
||||
## Custom Per-Particle Data
|
||||
|
||||
Add a custom channel via `popAttribCreateTOP` (or by writing through `glslcopyPOP`):
|
||||
|
||||
```python
|
||||
# Add a "phase" attribute initialized random per-particle, used in render shader
|
||||
attr = geo.create(popAttribCreateTOP, 'add_phase')
|
||||
attr.par.attribname = 'phase'
|
||||
attr.par.value0 = 'rand(@id)' # expression in TD's POP attribute language
|
||||
```
|
||||
|
||||
Then in the render shader, `texture(sTDPOPInputs[0].phase, ...)` (or whichever sampler convention your TD version uses — verify with `td_get_docs(topic='pops')`).
|
||||
|
||||
---
|
||||
|
||||
## Legacy particleSOP (Use Sparingly)
|
||||
|
||||
For quick demos or low-count systems:
|
||||
|
||||
```python
|
||||
# Inside a geo
|
||||
psrc = geo.create(addSOP, 'point_src') # source: a single point
|
||||
psrc.par.points = '0 0 0'
|
||||
|
||||
part = geo.create(particleSOP, 'particles')
|
||||
part.par.life = 3.0
|
||||
part.par.birthrate = 100
|
||||
part.par.gravityy = -9.8
|
||||
part.par.windx = 0.5
|
||||
part.inputConnectors[0].connect(psrc)
|
||||
```
|
||||
|
||||
CPU-bound. Beyond ~5,000 active particles you'll see frame drops.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Particles don't appear** — usually a render-side issue. Check via `td_get_screenshot` on the solver output (renders the buffer as a TOP-like view in newer TD). Then check the `geometryCOMP`'s render path.
|
||||
2. **Burst won't fire** — verify the `burst` param is a pulse, not a toggle. Pulses must use `.pulse()`, not `= True`.
|
||||
3. **Particles teleport on first frame** — uninitialized velocity. Set `popSourceTOP.par.initialvelocityX/Y/Z` or zero them explicitly.
|
||||
4. **Gravity feels wrong** — TD's "1 unit" depends on your scene scale. Start with `fy = -1.0` and scale up rather than using real-world 9.8.
|
||||
5. **High birthrate = stuttering** — birthrate is per-second, not per-frame. At 60fps, `birthrate = 6000` is 100/frame which is fine; `birthrate = 600000` will tank.
|
||||
6. **POP solver order matters** — forces apply in the order they appear in the chain. Putting gravity AFTER drag dampens gravity itself; usually not what you want.
|
||||
7. **Instancing param name varies** — `mat.par.instancingTOP` vs. `mat.par.instanceop` vs. `mat.par.instances` differs across TD versions. Always check `td_get_par_info(op_type='glslMAT')`.
|
||||
8. **Cooking dependency loops** — POP solvers create implicit time-loops. The "cook dependency loop" warning is expected and harmless for POPs.
|
||||
9. **CHOP-driven force values** — when a force param is expression-bound to a CHOP (e.g., audio-reactive gravity), make sure the CHOP cooks before the solver. If not, force lags by one frame.
|
||||
|
||||
---
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Particle count | Setup | Frame budget @ 60fps |
|
||||
|---|---|---|
|
||||
| < 1k | particleSOP fine | trivial |
|
||||
| 1k - 10k | POPs, simple forces | ~2-5ms |
|
||||
| 10k - 100k | POPs, GPU-only forces | ~5-15ms |
|
||||
| 100k+ | `glslcopyPOP`, custom compute | ~10-25ms |
|
||||
| 1M+ | Custom GPU buffer, no POP framework | depends on shader |
|
||||
|
||||
Use `td_get_perf` to find which op in the POP chain is the bottleneck.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Pipeline |
|
||||
|---|---|
|
||||
| Smoke plume | `popSourceTOP` (point) → gravity + wind + noise → `popDeleteTOP` (life) → solver → glslMAT instancing |
|
||||
| Beat-triggered burst | `triggerCHOP` (audio) → chopExecuteDAT pulses `popSourceTOP.par.burst` |
|
||||
| Fireworks shell | Burst at point → drag + gravity → secondary burst on lifetime threshold |
|
||||
| Snow/rain | Continuous emission across XZ plane (high y), gravity + small wind, infinite life box-deleted |
|
||||
| Sparks | Burst, very short life (0.3s), bright additive render, motion blur via feedback |
|
||||
| Audio particles | Birthrate driven by audio envelope, color driven by frequency band |
|
||||
@@ -1,211 +0,0 @@
|
||||
# Projection Mapping Reference
|
||||
|
||||
Multi-window output, surface mapping, edge blending, and projector calibration patterns for installation/event work.
|
||||
|
||||
For HUD layouts and on-screen panel grids, see `layout-compositor.md`. For wireframe/test-pattern generation, see `operator-tips.md`.
|
||||
|
||||
---
|
||||
|
||||
## Window COMP — Output to a Display
|
||||
|
||||
The `windowCOMP` is how TD pushes pixels to a real display.
|
||||
|
||||
```python
|
||||
win = root.create(windowCOMP, 'output_window')
|
||||
win.par.winop = '/project1/final_out' # path to the TOP being displayed
|
||||
win.par.winw = 1920
|
||||
win.par.winh = 1080
|
||||
win.par.winoffsetx = 0 # screen-space offset
|
||||
win.par.winoffsety = 0
|
||||
win.par.borders = False # no chrome
|
||||
win.par.alwaysontop = True
|
||||
win.par.cursor = False # hide cursor in fullscreen
|
||||
win.par.justify = 'fillaspect' # 'fill' | 'fitaspect' | 'fillaspect' | 'native'
|
||||
win.par.winopen.pulse() # OPEN the window
|
||||
```
|
||||
|
||||
To target a specific physical display, set `par.location`:
|
||||
|
||||
```python
|
||||
win.par.location = 'secondary' # 'primary' | 'secondary' | 'monitor1' | 'monitor2' | ...
|
||||
```
|
||||
|
||||
Or set absolute coordinates using `winoffsetx/y` matched to your OS display layout.
|
||||
|
||||
**Always pulse `winopen` — setting params alone doesn't open the window.**
|
||||
|
||||
---
|
||||
|
||||
## Multi-Window Output
|
||||
|
||||
For multi-projector or multi-display setups, create one `windowCOMP` per output, each pointing at a different TOP.
|
||||
|
||||
```python
|
||||
for i, screen_top in enumerate(['out_left', 'out_center', 'out_right']):
|
||||
w = root.create(windowCOMP, f'win_{i}')
|
||||
w.par.winop = f'/project1/{screen_top}'
|
||||
w.par.winw = 1920; w.par.winh = 1080
|
||||
w.par.winoffsetx = i * 1920
|
||||
w.par.winoffsety = 0
|
||||
w.par.borders = False
|
||||
w.par.alwaysontop = True
|
||||
w.par.cursor = False
|
||||
w.par.winopen.pulse()
|
||||
```
|
||||
|
||||
For ultra-wide single-output spans, use ONE windowCOMP at e.g. 5760×1080 spanning three projectors via the GPU's mosaic/spanning mode (Nvidia Mosaic, AMD Eyefinity), then split content via `cropTOP` per screen inside TD.
|
||||
|
||||
---
|
||||
|
||||
## 4-Point Corner Pin (Quad Warp)
|
||||
|
||||
The simplest projection mapping primitive — warping a rectangle onto a quadrilateral.
|
||||
|
||||
```python
|
||||
# Source content
|
||||
src = op('/project1/scene_out')
|
||||
|
||||
# Manual: cornerPinTOP (TD has this built-in)
|
||||
cp = root.create(cornerPinTOP, 'corner_pin')
|
||||
cp.par.tlx = 0.05; cp.par.tly = 0.10 # top-left (normalized 0-1)
|
||||
cp.par.trx = 0.95; cp.par.try = 0.08 # top-right
|
||||
cp.par.brx = 0.93; cp.par.bry = 0.92 # bottom-right
|
||||
cp.par.blx = 0.07; cp.par.bly = 0.94 # bottom-left
|
||||
cp.inputConnectors[0].connect(src)
|
||||
```
|
||||
|
||||
Alternative: use a `geometryCOMP` with a `gridSOP` and bend the verts in vertex GLSL. More flexible (curved surfaces) but more setup.
|
||||
|
||||
Verify TD 2025.32 param names with `td_get_par_info(op_type='cornerPinTOP')`.
|
||||
|
||||
---
|
||||
|
||||
## Bezier / Mesh Warp (Curved Surfaces)
|
||||
|
||||
For non-flat surfaces (domes, columns, curved walls), use a subdivided mesh and per-vertex displacement.
|
||||
|
||||
### Pattern: Grid Mesh + GLSL Displacement
|
||||
|
||||
```python
|
||||
# Subdivided grid in a geo
|
||||
geo = root.create(geometryCOMP, 'warp_geo')
|
||||
grid = geo.create(gridSOP, 'warp_grid')
|
||||
grid.par.rows = 32 # higher = smoother curve
|
||||
grid.par.cols = 32
|
||||
grid.par.sizex = 2; grid.par.sizey = 2
|
||||
|
||||
# Texture the source onto it
|
||||
mat = root.create(constMAT, 'warp_mat') # use constMAT for unlit projection
|
||||
mat.par.maptop = '/project1/scene_out' # source TOP
|
||||
|
||||
geo.par.material = mat.path
|
||||
|
||||
# Render to a TOP that goes to the projector window
|
||||
cam = root.create(cameraCOMP, 'cam_proj')
|
||||
cam.par.tz = 4
|
||||
|
||||
render = root.create(renderTOP, 'projection_out')
|
||||
render.par.camera = cam.path
|
||||
render.par.geometry = geo.path
|
||||
render.par.outputresolution = 'custom'
|
||||
render.par.resolutionw = 1920; render.par.resolutionh = 1080
|
||||
```
|
||||
|
||||
For per-vertex offsets, write a vertex GLSL on the constMAT (or use `glslMAT`) and read displacement values from a CHOP via uniform.
|
||||
|
||||
Calibration is iterative: render a checkerboard from `scene_out`, project it, photograph the projection, manually nudge corner/grid points until aligned.
|
||||
|
||||
---
|
||||
|
||||
## Edge Blending (Multi-Projector Overlap)
|
||||
|
||||
When two projectors overlap, the overlap region is twice as bright. Blend by ramping each projector's edge alpha to 0 across the overlap zone.
|
||||
|
||||
### GLSL Edge Blend Shader
|
||||
|
||||
Per-projector output pass that fades the inside edge to black:
|
||||
|
||||
```glsl
|
||||
// edge_blend_pixel.glsl
|
||||
out vec4 fragColor;
|
||||
uniform float uBlendLeft; // overlap width on left edge (0-0.5, 0=no blend)
|
||||
uniform float uBlendRight;
|
||||
uniform float uGamma; // typically 2.2 — perceptual ramp
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUV.st;
|
||||
vec4 col = texture(sTD2DInputs[0], uv);
|
||||
|
||||
float aL = (uBlendLeft > 0.0) ? smoothstep(0.0, uBlendLeft, uv.x) : 1.0;
|
||||
float aR = (uBlendRight > 0.0) ? smoothstep(0.0, uBlendRight, 1.0 - uv.x) : 1.0;
|
||||
float a = pow(aL * aR, uGamma);
|
||||
|
||||
fragColor = TDOutputSwizzle(vec4(col.rgb * a, 1.0));
|
||||
}
|
||||
```
|
||||
|
||||
Apply this to each overlap-touching projector's output. Tune `uBlendLeft` / `uBlendRight` to match your physical overlap.
|
||||
|
||||
For top/bottom blends or cylindrical setups, extend the shader with `uBlendTop` / `uBlendBottom`.
|
||||
|
||||
---
|
||||
|
||||
## Calibration Patterns
|
||||
|
||||
Useful test patterns for aligning projectors. Build a `switchTOP` selecting one of these, route to all projector windows during setup.
|
||||
|
||||
```python
|
||||
# Solid white — for brightness/uniformity check
|
||||
white = root.create(constantTOP, 'cal_white')
|
||||
white.par.colorr = 1.0; white.par.colorg = 1.0; white.par.colorb = 1.0
|
||||
|
||||
# Centered crosshair — for keystone alignment
|
||||
gridcross = root.create(textTOP, 'cal_cross')
|
||||
gridcross.par.text = '+'
|
||||
gridcross.par.fontsizex = 200
|
||||
|
||||
# Fine grid — for warp/mesh alignment (use rampTOP + math + threshold, or build via GLSL)
|
||||
# Color bars for projector color calibration
|
||||
bars = root.create(rampTOP, 'cal_bars')
|
||||
bars.par.type = 'horizontal'
|
||||
```
|
||||
|
||||
Or use the bundled `testpatternTOP` if your TD version includes it.
|
||||
|
||||
---
|
||||
|
||||
## Projection Audit Workflow
|
||||
|
||||
When debugging a multi-screen setup:
|
||||
|
||||
1. Render a unique color and label per output (`textTOP` saying "LEFT", "CENTER", "RIGHT").
|
||||
2. Check that each window is sourcing the correct path: `td_get_operator_info(path='/project1/win_0')`.
|
||||
3. Verify display assignment: walk to each projector and confirm visually.
|
||||
4. Check resolution: physical projector native res vs. TD output res — mismatches cause scaling artifacts.
|
||||
5. Cook flag: `td_get_perf` — if a window's source TOP isn't cooking, the projector shows last frame frozen.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Window won't open** — you forgot `winopen.pulse()`. Setting params alone doesn't open it.
|
||||
2. **Wrong display** — `par.location='secondary'` depends on OS display order. Set `winoffsetx/y` to absolute coords as a more reliable override.
|
||||
3. **Cursor visible** — set `par.cursor = False` BEFORE opening, or close+reopen.
|
||||
4. **Black projection** — usually a cooking issue. Verify `final_out` TOP is cooking via `td_get_perf`. Check `td_get_errors` recursively from `/`.
|
||||
5. **Tearing / vsync** — `windowCOMP` honors `par.vsync`. For projection always set `vsync='vsync'` (default). Tearing means GPU is over-budget — reduce render resolution.
|
||||
6. **Aspect mismatch** — projector native is often 1920×1200 (16:10) not 1080. Use `justify='fitaspect'` or render at native projector res.
|
||||
7. **Non-Commercial license** — caps total resolution at 1280×1280. For real installation work you need Commercial. Pro license adds 4K+.
|
||||
8. **Multiple monitors on macOS** — `windowCOMP` honors macOS Spaces. Disable Spaces or pin TD to a specific display in System Settings before showtime.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Approach |
|
||||
|---|---|
|
||||
| Single fullscreen output | One `windowCOMP`, `justify='fillaspect'`, `winopen.pulse()` |
|
||||
| 3-projector wide span | 3 `windowCOMP` + per-output `cropTOP` from one wide source |
|
||||
| Single quad surface | `cornerPinTOP` → `windowCOMP` |
|
||||
| Curved/dome | Subdivided gridSOP with vertex GLSL → `renderTOP` → `windowCOMP` |
|
||||
| Edge blend overlap | GLSL fade shader per projector → `windowCOMP` |
|
||||
| Calibration mode | `switchTOP` between scene and test patterns, hot-key triggered |
|
||||
@@ -1,198 +0,0 @@
|
||||
# Replicator COMP Reference
|
||||
|
||||
The `replicatorCOMP` clones a template operator N times, driven by a table of data. The fundamental TD pattern for data-driven networks: button grids, scene rosters, dynamic UI, parameter panels per-channel.
|
||||
|
||||
For visual instancing (per-pixel/per-render copies), see `geometry-comp.md`. Replicator builds NETWORK NODES; instancing builds RENDER COPIES. Different layer.
|
||||
|
||||
---
|
||||
|
||||
## Concept
|
||||
|
||||
```
|
||||
[Template OP] [Data tableDAT]
|
||||
│ │
|
||||
└─────→ replicatorCOMP ←───────┘
|
||||
│
|
||||
▼
|
||||
[N clones], one per data row
|
||||
Each clone gets per-row params
|
||||
```
|
||||
|
||||
Edit the template once → all clones inherit. Edit the table → clones add/remove dynamically. Push parameter overrides per-row.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Setup
|
||||
|
||||
```python
|
||||
# 1. Make a template (the thing to clone)
|
||||
template = root.create(buttonCOMP, 'btn_template')
|
||||
template.par.w = 80; template.par.h = 80
|
||||
template.par.text = 'X'
|
||||
template.par.bgcolorr = 0.2
|
||||
|
||||
# 2. Make a data table (one row per clone)
|
||||
data = root.create(tableDAT, 'scene_data')
|
||||
data.appendRow(['name', 'color_r', 'color_g', 'color_b'])
|
||||
data.appendRow(['Sunset', 1.0, 0.4, 0.0])
|
||||
data.appendRow(['Midnight', 0.0, 0.1, 0.4])
|
||||
data.appendRow(['Storm', 0.3, 0.3, 0.5])
|
||||
data.appendRow(['Forest', 0.0, 0.5, 0.2])
|
||||
|
||||
# 3. Replicator — points at template + data
|
||||
rep = root.create(replicatorCOMP, 'scene_buttons')
|
||||
rep.par.template = template.path
|
||||
rep.par.opfromdat = data.path
|
||||
rep.par.namefromdatname = 'name' # use 'name' column for clone names
|
||||
rep.par.incrementalnumbering = False
|
||||
```
|
||||
|
||||
After cooking, the replicator creates 4 child COMPs named `Sunset`, `Midnight`, `Storm`, `Forest` (one per non-header row), each cloned from `btn_template`.
|
||||
|
||||
---
|
||||
|
||||
## Per-Row Parameter Overrides
|
||||
|
||||
The replicator's docked `replicator1_callbacks` DAT lets you customize each clone:
|
||||
|
||||
```python
|
||||
def onReplicate(comp, allOps, newOps, template, master):
|
||||
"""Called once per replicate cycle. newOps is the list of just-created clones."""
|
||||
data = op('scene_data')
|
||||
for i, clone in enumerate(newOps):
|
||||
row = i + 1 # +1 to skip header
|
||||
clone.par.text = data[row, 'name'].val
|
||||
clone.par.bgcolorr = float(data[row, 'color_r'].val)
|
||||
clone.par.bgcolorg = float(data[row, 'color_g'].val)
|
||||
clone.par.bgcolorb = float(data[row, 'color_b'].val)
|
||||
return
|
||||
```
|
||||
|
||||
Or use parameter expressions referencing `digits` (the per-clone index, available as a built-in expression token inside the cloned subtree):
|
||||
|
||||
```python
|
||||
# Inside the template, set a param expression like:
|
||||
# par.value0.expr = "op('../scene_data')[me.digits + 1, 'value']"
|
||||
```
|
||||
|
||||
`me.digits` resolves to the row index of the current clone. This is the cleanest way for static reference patterns — no callback needed.
|
||||
|
||||
---
|
||||
|
||||
## Layout: Buttons in a Grid
|
||||
|
||||
Drop the replicator inside a `containerCOMP` with auto-layout:
|
||||
|
||||
```python
|
||||
panel = root.create(containerCOMP, 'scene_panel')
|
||||
panel.par.w = 400; panel.par.h = 100
|
||||
panel.par.align = 'lefttoright'
|
||||
|
||||
# Move the replicator inside
|
||||
rep.parent = panel.path # or create rep as a child of panel directly
|
||||
```
|
||||
|
||||
Each clone is a child of the replicator (which itself is a child of the panel). The panel auto-arranges everything.
|
||||
|
||||
For a 2D grid, set `par.align = 'fillresize'` on the container and override `par.x` / `par.y` per clone in the callback based on row/col index.
|
||||
|
||||
---
|
||||
|
||||
## Updating Without Rebuilding
|
||||
|
||||
When the data table changes, the replicator regenerates the clones. By default it destroys and recreates everything. To preserve state, set:
|
||||
|
||||
```python
|
||||
rep.par.recreatemissing = True # only add/remove changed rows
|
||||
rep.par.recreateallonchange = False
|
||||
```
|
||||
|
||||
This pattern is essential for live-edit scenarios (designer adjusts table, network keeps running).
|
||||
|
||||
For incremental data ingestion (e.g., from a `webDAT` polling an API), have a `datExecuteDAT` watch the response, parse, write to the data table, and the replicator self-updates.
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Scene Roster (Data → Buttons + Logic)
|
||||
|
||||
```python
|
||||
# Data per scene: name, file path, audio track, BPM
|
||||
scene_data.appendRow(['name', 'file', 'audio', 'bpm'])
|
||||
scene_data.appendRow(['Intro', '/scenes/intro.tox', '/audio/intro.wav', 110])
|
||||
scene_data.appendRow(['Main', '/scenes/main.tox', '/audio/main.wav', 128])
|
||||
|
||||
# Replicator clones a buttonCOMP per scene
|
||||
# Each button's onClick callback loads the corresponding tox + cues audio
|
||||
```
|
||||
|
||||
### Dynamic Parameter Panel
|
||||
|
||||
For a list of audio bands, generate a fader strip per band:
|
||||
|
||||
```python
|
||||
# Data: band names (sub, low, mid, hi-mid, high, air)
|
||||
# Template: containerCOMP with label + sliderCOMP
|
||||
# Replicator clones N strips
|
||||
# Each slider's value is read at /audio_eq/{band_name}/fader
|
||||
```
|
||||
|
||||
### Procedural Visual Network
|
||||
|
||||
Build a multi-channel visual network from a config file:
|
||||
|
||||
```python
|
||||
# Data: which TOPs to chain, per "scene"
|
||||
# Template: a baseCOMP with placeholder children
|
||||
# Replicator builds one baseCOMP per scene; each scene contains a custom chain
|
||||
# Switch between scenes via switchTOP.par.index driven by panel
|
||||
```
|
||||
|
||||
### Per-Channel CHOP Display
|
||||
|
||||
Visualize each channel of a multi-channel CHOP separately:
|
||||
|
||||
```python
|
||||
# Data table: one row per channel (auto-extracted via choptodatDAT)
|
||||
# Template: a small chopVis COMP showing one channel
|
||||
# Replicator generates N visualizers stacked vertically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Replicator vs. Pure Python Loop
|
||||
|
||||
| Approach | When to use |
|
||||
|---|---|
|
||||
| **replicatorCOMP** | The set of clones changes (add/remove rows live). Visual editor expectations. Pattern is reusable across projects. |
|
||||
| **Python loop** (in `td_execute_python`) | One-shot generation. Static set. Simpler logic, no template overhead. Faster to write. |
|
||||
|
||||
If you'll only ever build the network once, prefer a Python loop with `td_execute_python`. The replicator earns its weight when data is live.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Header row** — `tableDAT` rows are 0-indexed. If you have a header, your first data row is index 1. Off-by-one bugs are common in callbacks.
|
||||
2. **`namefromdatname` column missing** — replicator silently uses `digits` (numeric suffix) names. Buttons end up named `1`, `2`, `3` instead of meaningful names. Set `par.namefromdatname` explicitly.
|
||||
3. **Template lives in network** — the template OP is itself a real network node. Don't connect things downstream of it directly; connect to the clones (or use a `nullCOMP` between).
|
||||
4. **Recreate-on-change wipes state** — toggles, slider positions, and uncached data inside clones are lost on each regeneration. Use `recreatemissing` to preserve.
|
||||
5. **`onReplicate` doesn't fire on edit** — only fires when the clone set changes. Editing a value WITHIN an existing row doesn't re-trigger. Use `parameterExecuteDAT` or expressions for per-cell live updates.
|
||||
6. **Custom params on clones** — pages added in the template propagate. Pages added in `onReplicate` don't survive the next regeneration. Always add custom pages on the template, not the clone.
|
||||
7. **Cooking storms** — adding many rows fast triggers many clone events. Bundle adds via Python and call `data.cook(force=True)` once at the end.
|
||||
8. **`me.digits` outside replicator children** — `me.digits` only resolves inside an op that's a descendant of the replicator. Don't reference it in unrelated networks.
|
||||
9. **Cross-clone references** — referencing a sibling clone via relative path works from inside a clone (`op('../OtherClone/x')`), but breaks if names change. Prefer absolute paths via the data table.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Setup |
|
||||
|---|---|
|
||||
| 8-button scene picker | `tableDAT` (8 rows) + `buttonCOMP` template + `replicatorCOMP` |
|
||||
| Per-band EQ strip panel | `tableDAT` (band names) + container template (label + slider) + replicator |
|
||||
| Data-driven visual scenes | `tableDAT` (scene config) + `baseCOMP` template (visual chain) + replicator |
|
||||
| Live-updating clone set | Same as above + `par.recreatemissing = True` |
|
||||
| Per-row colored UI | Data table with color cols, `onReplicate` callback sets per-clone colors |
|
||||
| List from API response | `webDAT` → `datExecuteDAT` parses JSON → writes to data table → replicator updates |
|
||||
@@ -1,105 +0,0 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -242,232 +242,6 @@ class TestSummaryFailureCooldown:
|
||||
assert mock_call.call_count == 1
|
||||
|
||||
|
||||
class TestSummaryFallbackToMainModel:
|
||||
"""When ``summary_model`` differs from the main model and the summary LLM
|
||||
call fails, the compressor should retry once on the main model before
|
||||
giving up — losing N turns of context is almost always worse than one
|
||||
extra summary attempt. Covers both the fast-path (explicit
|
||||
model-not-found errors) and the unknown-error best-effort retry."""
|
||||
|
||||
def _msgs(self):
|
||||
return [
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
|
||||
def test_model_not_found_404_falls_back_to_main_and_succeeds(self):
|
||||
"""Classic misconfiguration: ``auxiliary.compression.model`` points at
|
||||
a model the main provider doesn't serve → 404 → retry on main."""
|
||||
mock_ok = MagicMock()
|
||||
mock_ok.choices = [MagicMock()]
|
||||
mock_ok.choices[0].message.content = "summary via main model"
|
||||
|
||||
err_404 = Exception("404 model_not_found: no such model")
|
||||
err_404.status_code = 404
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(
|
||||
model="main-model",
|
||||
summary_model_override="broken-aux-model",
|
||||
quiet_mode=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
side_effect=[err_404, mock_ok],
|
||||
) as mock_call:
|
||||
result = c._generate_summary(self._msgs())
|
||||
|
||||
assert mock_call.call_count == 2
|
||||
# First call used the misconfigured aux model
|
||||
assert mock_call.call_args_list[0].kwargs.get("model") == "broken-aux-model"
|
||||
# Second call used the main model (no model kwarg → call_llm uses main)
|
||||
assert "model" not in mock_call.call_args_list[1].kwargs
|
||||
assert result is not None
|
||||
assert "summary via main model" in result
|
||||
# Aux-model failure is recorded even though retry succeeded — this is
|
||||
# how callers (gateway /compress, CLI warning) know to tell the user
|
||||
# their auxiliary.compression.model setting is broken.
|
||||
assert c._last_aux_model_failure_model == "broken-aux-model"
|
||||
assert c._last_aux_model_failure_error is not None
|
||||
assert "404" in c._last_aux_model_failure_error
|
||||
|
||||
def test_unknown_error_falls_back_to_main_and_succeeds(self):
|
||||
"""Errors that don't match the 404/503/model_not_found fast-path
|
||||
(400s, provider-specific 'no route', aggregator rejections) should
|
||||
ALSO trigger a best-effort retry on main before entering cooldown."""
|
||||
mock_ok = MagicMock()
|
||||
mock_ok.choices = [MagicMock()]
|
||||
mock_ok.choices[0].message.content = "summary via main model"
|
||||
|
||||
# A 400 from OpenRouter / Nous portal with an opaque message — does
|
||||
# NOT match _is_model_not_found, but still an unrecoverable misconfig.
|
||||
err_400 = Exception("400 Bad Request: provider rejected model")
|
||||
err_400.status_code = 400
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(
|
||||
model="main-model",
|
||||
summary_model_override="broken-aux-model",
|
||||
quiet_mode=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
side_effect=[err_400, mock_ok],
|
||||
) as mock_call:
|
||||
result = c._generate_summary(self._msgs())
|
||||
|
||||
assert mock_call.call_count == 2
|
||||
assert mock_call.call_args_list[0].kwargs.get("model") == "broken-aux-model"
|
||||
assert "model" not in mock_call.call_args_list[1].kwargs
|
||||
assert result is not None
|
||||
assert "summary via main model" in result
|
||||
# Aux-model failure recorded despite successful recovery
|
||||
assert c._last_aux_model_failure_model == "broken-aux-model"
|
||||
assert c._last_aux_model_failure_error is not None
|
||||
assert "400" in c._last_aux_model_failure_error
|
||||
|
||||
def test_no_fallback_when_summary_model_equals_main_model(self):
|
||||
"""If the aux model IS the main model, there's nowhere to fall back
|
||||
to — go straight to cooldown, don't loop retrying the same call."""
|
||||
err = Exception("500 internal error")
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(
|
||||
model="main-model",
|
||||
summary_model_override="main-model", # same as main
|
||||
quiet_mode=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
side_effect=err,
|
||||
) as mock_call:
|
||||
result = c._generate_summary(self._msgs())
|
||||
|
||||
# Only one attempt — retry gate blocks fallback when models match
|
||||
assert mock_call.call_count == 1
|
||||
assert result is None
|
||||
# Not flagged as fallen back — the retry condition was never met
|
||||
assert getattr(c, "_summary_model_fallen_back", False) is False
|
||||
|
||||
def test_fallback_only_happens_once_per_compressor(self):
|
||||
"""If the retry-on-main ALSO fails, don't loop forever — enter
|
||||
cooldown like the normal failure path."""
|
||||
err1 = Exception("400 aux model rejected")
|
||||
err2 = Exception("500 main model also exploded")
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(
|
||||
model="main-model",
|
||||
summary_model_override="broken-aux-model",
|
||||
quiet_mode=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
side_effect=[err1, err2],
|
||||
) as mock_call:
|
||||
result = c._generate_summary(self._msgs())
|
||||
|
||||
# Exactly 2 calls: initial + one retry on main. No further retries.
|
||||
assert mock_call.call_count == 2
|
||||
assert result is None
|
||||
assert c._summary_model_fallen_back is True
|
||||
|
||||
|
||||
class TestAuxModelFallbackSurfacedToCallers:
|
||||
"""When summary_model fails but retry-on-main succeeds, compress() must
|
||||
expose the aux-model failure via _last_aux_model_failure_{model,error}
|
||||
so gateway /compress and CLI callers can warn the user about their
|
||||
broken auxiliary.compression.model config — silent recovery would hide
|
||||
a misconfiguration only the user can fix."""
|
||||
|
||||
def _make_msgs(self):
|
||||
return [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "msg 1"},
|
||||
{"role": "assistant", "content": "msg 2"},
|
||||
{"role": "user", "content": "msg 3"},
|
||||
{"role": "assistant", "content": "msg 4"},
|
||||
{"role": "user", "content": "msg 5"},
|
||||
{"role": "assistant", "content": "msg 6"},
|
||||
{"role": "user", "content": "msg 7"},
|
||||
]
|
||||
|
||||
def test_compress_exposes_aux_failure_fields_after_successful_fallback(self):
|
||||
mock_ok = MagicMock()
|
||||
mock_ok.choices = [MagicMock()]
|
||||
mock_ok.choices[0].message.content = "summary via main"
|
||||
err_400 = Exception("400 provider rejected configured model")
|
||||
err_400.status_code = 400
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(
|
||||
model="main-model",
|
||||
summary_model_override="broken-aux-model",
|
||||
quiet_mode=True,
|
||||
protect_first_n=2,
|
||||
protect_last_n=2,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
side_effect=[err_400, mock_ok],
|
||||
):
|
||||
result = c.compress(self._make_msgs())
|
||||
|
||||
# Recovery succeeded → no fallback placeholder
|
||||
assert c._last_summary_fallback_used is False
|
||||
# But aux-model failure IS recorded for the gateway/CLI warning
|
||||
assert c._last_aux_model_failure_model == "broken-aux-model"
|
||||
assert c._last_aux_model_failure_error is not None
|
||||
assert "400" in c._last_aux_model_failure_error
|
||||
# Result is well-formed with a real summary, not a placeholder
|
||||
assert any(
|
||||
isinstance(m.get("content"), str) and "summary via main" in m["content"]
|
||||
for m in result
|
||||
)
|
||||
|
||||
def test_compress_clears_aux_failure_fields_at_start_of_next_call(self):
|
||||
"""A subsequent successful compression must clear the aux-failure
|
||||
fields so the warning doesn't persist forever."""
|
||||
mock_ok = MagicMock()
|
||||
mock_ok.choices = [MagicMock()]
|
||||
mock_ok.choices[0].message.content = "summary via main"
|
||||
err_400 = Exception("400 aux model busted")
|
||||
err_400.status_code = 400
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(
|
||||
model="main-model",
|
||||
summary_model_override="broken-aux-model",
|
||||
quiet_mode=True,
|
||||
protect_first_n=2,
|
||||
protect_last_n=2,
|
||||
)
|
||||
|
||||
# Call 1: aux fails, retry-on-main succeeds
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
side_effect=[err_400, mock_ok],
|
||||
):
|
||||
c.compress(self._make_msgs())
|
||||
assert c._last_aux_model_failure_model == "broken-aux-model"
|
||||
|
||||
# Call 2: clean run on main (summary_model was cleared to "" after
|
||||
# first fallback). Aux-failure fields MUST reset at compress() start
|
||||
# so the old warning state doesn't leak into this call.
|
||||
with patch(
|
||||
"agent.context_compressor.call_llm",
|
||||
return_value=mock_ok,
|
||||
):
|
||||
c.compress(self._make_msgs())
|
||||
assert c._last_aux_model_failure_model is None
|
||||
assert c._last_aux_model_failure_error is None
|
||||
|
||||
|
||||
class TestSummaryFailureTrackingForGatewayWarning:
|
||||
"""When summary generation fails, the compressor must record dropped count
|
||||
+ fallback flag so gateway hygiene & /compress can surface a visible
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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`)
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,333 +0,0 @@
|
||||
"""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)
|
||||
@@ -181,65 +181,3 @@ async def test_compress_command_appends_warning_when_summary_generation_fails():
|
||||
assert "historical message(s) were removed" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_command_surfaces_aux_model_failure_even_when_recovered():
|
||||
"""When the user's configured ``auxiliary.compression.model`` errors out
|
||||
but compression recovers by retrying on the main model, /compress must
|
||||
STILL inform the user. Silent recovery hides broken config the user
|
||||
needs to fix."""
|
||||
history = _make_history()
|
||||
# Compressed transcript — normal successful compression, no placeholder.
|
||||
compressed = [
|
||||
history[0],
|
||||
{"role": "assistant", "content": "summary via main model"},
|
||||
history[-1],
|
||||
]
|
||||
runner = _make_runner(history)
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance.context_compressor.has_content_to_compress.return_value = True
|
||||
# Fallback placeholder was NOT used — recovery succeeded.
|
||||
agent_instance.context_compressor._last_summary_fallback_used = False
|
||||
agent_instance.context_compressor._last_summary_dropped_count = 0
|
||||
agent_instance.context_compressor._last_summary_error = None
|
||||
# But the configured aux model DID fail before the retry succeeded.
|
||||
agent_instance.context_compressor._last_aux_model_failure_model = (
|
||||
"gemini-3-flash-preview"
|
||||
)
|
||||
agent_instance.context_compressor._last_aux_model_failure_error = (
|
||||
"404 model not found: gemini-3-flash-preview"
|
||||
)
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
if messages == history:
|
||||
return 100
|
||||
if messages == compressed:
|
||||
return 60
|
||||
raise AssertionError(f"unexpected transcript: {messages!r}")
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event())
|
||||
|
||||
# Compression succeeded
|
||||
assert "Compressed:" in result
|
||||
# No ⚠️ warning (that's reserved for dropped-turns case)
|
||||
assert "⚠️" not in result
|
||||
# But there IS an info note about the broken aux model
|
||||
assert "ℹ️" in result
|
||||
assert "gemini-3-flash-preview" in result
|
||||
assert "404" in result
|
||||
assert "auxiliary.compression.model" in result
|
||||
# The user's context is explicitly called out as intact
|
||||
assert "intact" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
|
||||
@@ -9,7 +9,6 @@ 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():
|
||||
@@ -1205,40 +1204,6 @@ 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
|
||||
@@ -1897,81 +1862,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2209,139 +2099,3 @@ 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()
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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):
|
||||
"""Bare localpart (no @) is preserved — avoids false positives in paths."""
|
||||
"""Localpart-only text is no longer stripped — avoids false positives in paths."""
|
||||
result = self.adapter._strip_mention("hermes help me")
|
||||
assert result == "hermes help me"
|
||||
|
||||
@@ -168,98 +168,11 @@ 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,8 +3,6 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
|
||||
@@ -584,47 +582,3 @@ 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"}
|
||||
|
||||
|
||||
@@ -508,128 +508,4 @@ async def test_session_hygiene_warns_user_when_summary_generation_fails(monkeypa
|
||||
assert warn["chat_id"] == "-1001"
|
||||
assert warn["metadata"] == {"thread_id": "17585"}
|
||||
|
||||
FakeCompressAgentWithSummaryFailure.last_instance.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_hygiene_informs_user_when_aux_model_fails_but_recovers(monkeypatch, tmp_path):
|
||||
"""When the user's configured ``auxiliary.compression.model`` errors out
|
||||
and we recover via the main model, compression succeeds but the user's
|
||||
config is still broken. Gateway hygiene must surface an ℹ note so the
|
||||
user knows to fix ``auxiliary.compression.model`` — silent recovery
|
||||
hides a misconfig only they can resolve."""
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
class FakeCompressAgentWithAuxRecovery:
|
||||
last_instance = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.model = kwargs.get("model")
|
||||
self.session_id = kwargs.get("session_id", "fake-session")
|
||||
self._print_fn = None
|
||||
self.shutdown_memory_provider = MagicMock()
|
||||
self.close = MagicMock()
|
||||
# Compression succeeded (no placeholder inserted) but the
|
||||
# configured aux model errored and we fell back to main.
|
||||
self.context_compressor = SimpleNamespace(
|
||||
_last_summary_fallback_used=False,
|
||||
_last_summary_dropped_count=0,
|
||||
_last_summary_error=None,
|
||||
_last_aux_model_failure_model="gemini-3-flash-preview",
|
||||
_last_aux_model_failure_error="404 model not found",
|
||||
)
|
||||
type(self).last_instance = self
|
||||
|
||||
def _compress_context(self, messages, *_args, **_kwargs):
|
||||
self.session_id = f"{self.session_id}_compressed"
|
||||
return ([{"role": "assistant", "content": "real summary"}], None)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = FakeCompressAgentWithAuxRecovery
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
GatewayRunner = gateway_run.GatewayRunner
|
||||
|
||||
adapter = HygieneCaptureAdapter()
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||||
)
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||||
session_key="agent:main:telegram:group:-1001:17585",
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="group",
|
||||
)
|
||||
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
|
||||
runner.session_store.has_any_sessions.return_value = True
|
||||
runner.session_store.rewrite_transcript = MagicMock()
|
||||
runner.session_store.append_to_transcript = MagicMock()
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._session_db = None
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner._set_session_env = lambda _context: None
|
||||
runner._run_agent = AsyncMock(
|
||||
return_value={
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"tools": [],
|
||||
"history_offset": 0,
|
||||
"last_prompt_tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
monkeypatch.setattr(
|
||||
"agent.model_metadata.get_model_context_length",
|
||||
lambda *_args, **_kwargs: 100,
|
||||
)
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "795544298")
|
||||
|
||||
event = MessageEvent(
|
||||
text="hello",
|
||||
source=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
user_id="12345",
|
||||
),
|
||||
message_id="1",
|
||||
)
|
||||
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert result == "ok"
|
||||
# No ⚠️ hard-failure warning (that's for dropped turns)
|
||||
hard_warnings = [s for s in adapter.sent if "Context compression summary failed" in s["content"]]
|
||||
assert len(hard_warnings) == 0, adapter.sent
|
||||
# But an ℹ note about the configured aux model must be delivered.
|
||||
aux_notes = [
|
||||
s for s in adapter.sent
|
||||
if "Configured compression model" in s["content"]
|
||||
]
|
||||
assert len(aux_notes) == 1, (
|
||||
f"Expected 1 aux-model fallback notice, got {len(aux_notes)}: {adapter.sent}"
|
||||
)
|
||||
note = aux_notes[0]
|
||||
assert "gemini-3-flash-preview" in note["content"]
|
||||
assert "404" in note["content"]
|
||||
assert "auxiliary.compression.model" in note["content"]
|
||||
# Note must land in the originating topic/thread.
|
||||
assert note["chat_id"] == "-1001"
|
||||
assert note["metadata"] == {"thread_id": "17585"}
|
||||
|
||||
FakeCompressAgentWithAuxRecovery.last_instance.close.assert_called_once()
|
||||
FakeCompressAgentWithSummaryFailure.last_instance.close.assert_called_once()
|
||||
@@ -1,168 +0,0 @@
|
||||
"""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,12 +72,8 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path):
|
||||
assert "ENV_VAR=false" in result.stdout
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
def test_redact_secrets_default_true_when_unset(tmp_path):
|
||||
"""Without the config key, redaction stays on by default."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text("{}\n") # empty config
|
||||
@@ -107,53 +103,7 @@ def test_redact_secrets_default_false_when_unset(tmp_path):
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"probe failed: {result.stderr}"
|
||||
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
|
||||
assert "REDACT_ENABLED=True" in result.stdout
|
||||
|
||||
|
||||
def test_dotenv_redact_secrets_beats_config_yaml(tmp_path):
|
||||
|
||||
@@ -1373,144 +1373,6 @@ 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."""
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
"""Tests for pre_approval_request / post_approval_response plugin hooks.
|
||||
|
||||
These hooks fire in tools/approval.py::check_all_command_guards whenever a
|
||||
dangerous command needs user approval. They are observer-only (return values
|
||||
ignored) and must fire on BOTH the CLI-interactive path and the async gateway
|
||||
path, so external tools like macOS notifiers can be alerted regardless of
|
||||
which surface the user is on.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.approval as approval_module
|
||||
from tools.approval import (
|
||||
check_all_command_guards,
|
||||
register_gateway_notify,
|
||||
unregister_gateway_notify,
|
||||
resolve_gateway_approval,
|
||||
set_current_session_key,
|
||||
clear_session,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_session(monkeypatch):
|
||||
"""Give each test a fresh session_key and clean approval-state."""
|
||||
session_key = "test:session:approval_hooks"
|
||||
token = set_current_session_key(session_key)
|
||||
monkeypatch.setenv("HERMES_SESSION_KEY", session_key)
|
||||
# Make sure we don't skip guards via yolo / approvals.mode=off
|
||||
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
||||
try:
|
||||
yield session_key
|
||||
finally:
|
||||
try:
|
||||
approval_module._approval_session_key.reset(token)
|
||||
except Exception:
|
||||
pass
|
||||
clear_session(session_key)
|
||||
|
||||
|
||||
class TestCliPathFiresHooks:
|
||||
"""CLI-interactive approval path: HERMES_INTERACTIVE is set, the
|
||||
prompt_dangerous_approval() result decides the outcome."""
|
||||
|
||||
def test_pre_and_post_fire_with_expected_kwargs(
|
||||
self, isolated_session, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
# approvals.mode=manual so we actually reach the prompt site
|
||||
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
captured.append((hook_name, kwargs))
|
||||
return []
|
||||
|
||||
# Force the user to "approve once" via the approval_callback contract
|
||||
def cb(command, description, *, allow_permanent=True):
|
||||
return "once"
|
||||
|
||||
with patch("hermes_cli.plugins.invoke_hook", side_effect=fake_invoke_hook):
|
||||
result = check_all_command_guards(
|
||||
"rm -rf /tmp/test-hook", "local", approval_callback=cb,
|
||||
)
|
||||
|
||||
assert result["approved"] is True
|
||||
|
||||
hook_names = [c[0] for c in captured]
|
||||
assert "pre_approval_request" in hook_names
|
||||
assert "post_approval_response" in hook_names
|
||||
|
||||
pre_kwargs = next(kw for name, kw in captured if name == "pre_approval_request")
|
||||
assert pre_kwargs["command"] == "rm -rf /tmp/test-hook"
|
||||
assert pre_kwargs["surface"] == "cli"
|
||||
assert pre_kwargs["session_key"] == isolated_session
|
||||
assert isinstance(pre_kwargs["pattern_keys"], list)
|
||||
assert pre_kwargs["pattern_key"] # non-empty primary pattern
|
||||
assert pre_kwargs["description"]
|
||||
|
||||
post_kwargs = next(kw for name, kw in captured if name == "post_approval_response")
|
||||
assert post_kwargs["choice"] == "once"
|
||||
assert post_kwargs["surface"] == "cli"
|
||||
assert post_kwargs["command"] == "rm -rf /tmp/test-hook"
|
||||
|
||||
def test_deny_reported_to_post_hook(self, isolated_session, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
captured.append((hook_name, kwargs))
|
||||
return []
|
||||
|
||||
def cb(command, description, *, allow_permanent=True):
|
||||
return "deny"
|
||||
|
||||
with patch("hermes_cli.plugins.invoke_hook", side_effect=fake_invoke_hook):
|
||||
result = check_all_command_guards(
|
||||
"rm -rf /tmp/test-deny", "local", approval_callback=cb,
|
||||
)
|
||||
|
||||
assert result["approved"] is False
|
||||
post_kwargs = next(kw for name, kw in captured if name == "post_approval_response")
|
||||
assert post_kwargs["choice"] == "deny"
|
||||
|
||||
def test_plugin_hook_crash_does_not_break_approval(
|
||||
self, isolated_session, monkeypatch
|
||||
):
|
||||
"""A crashing plugin must never prevent the approval flow from
|
||||
reaching the user. Hooks are observer-only and safety-critical
|
||||
behavior must be preserved."""
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
|
||||
|
||||
def boom(hook_name, **kwargs):
|
||||
raise RuntimeError("plugin crashed")
|
||||
|
||||
def cb(command, description, *, allow_permanent=True):
|
||||
return "once"
|
||||
|
||||
with patch("hermes_cli.plugins.invoke_hook", side_effect=boom):
|
||||
result = check_all_command_guards(
|
||||
"rm -rf /tmp/test-crash", "local", approval_callback=cb,
|
||||
)
|
||||
|
||||
# User's approval was still honored despite the plugin crashing
|
||||
assert result["approved"] is True
|
||||
|
||||
|
||||
class TestGatewayPathFiresHooks:
|
||||
"""Async gateway approval path: HERMES_GATEWAY_SESSION is set and a
|
||||
gateway notify callback is registered. The agent thread blocks on the
|
||||
approval event until resolve_gateway_approval() is called from another
|
||||
thread."""
|
||||
|
||||
def test_pre_and_post_fire_on_gateway_surface(
|
||||
self, isolated_session, monkeypatch
|
||||
):
|
||||
import threading
|
||||
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
|
||||
# Short gateway_timeout so a buggy test fails fast instead of hanging
|
||||
monkeypatch.setattr(
|
||||
approval_module, "_get_approval_config", lambda: {"gateway_timeout": 10}
|
||||
)
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
captured.append((hook_name, kwargs))
|
||||
return []
|
||||
|
||||
notify_seen = threading.Event()
|
||||
|
||||
def notify_cb(approval_data):
|
||||
notify_seen.set()
|
||||
|
||||
register_gateway_notify(isolated_session, notify_cb)
|
||||
result_holder = {}
|
||||
|
||||
def run_guard():
|
||||
with patch("hermes_cli.plugins.invoke_hook", side_effect=fake_invoke_hook):
|
||||
result_holder["result"] = check_all_command_guards(
|
||||
"rm -rf /tmp/test-gateway-hook", "local",
|
||||
)
|
||||
|
||||
t = threading.Thread(target=run_guard, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Wait for the gateway callback to see the approval request
|
||||
assert notify_seen.wait(timeout=5), "Gateway notify never fired"
|
||||
|
||||
# User approves from the "other thread" (simulating /approve command)
|
||||
resolve_gateway_approval(isolated_session, "once")
|
||||
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive(), "Agent thread never unblocked"
|
||||
unregister_gateway_notify(isolated_session)
|
||||
|
||||
assert result_holder["result"]["approved"] is True
|
||||
|
||||
hook_names = [c[0] for c in captured]
|
||||
assert "pre_approval_request" in hook_names
|
||||
assert "post_approval_response" in hook_names
|
||||
|
||||
pre_kwargs = next(kw for name, kw in captured if name == "pre_approval_request")
|
||||
assert pre_kwargs["surface"] == "gateway"
|
||||
assert pre_kwargs["command"] == "rm -rf /tmp/test-gateway-hook"
|
||||
|
||||
post_kwargs = next(kw for name, kw in captured if name == "post_approval_response")
|
||||
assert post_kwargs["surface"] == "gateway"
|
||||
assert post_kwargs["choice"] == "once"
|
||||
|
||||
def test_timeout_reports_timeout_choice(self, isolated_session, monkeypatch):
|
||||
import threading
|
||||
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
|
||||
monkeypatch.setattr(
|
||||
approval_module, "_get_approval_config", lambda: {"gateway_timeout": 1}
|
||||
)
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_invoke_hook(hook_name, **kwargs):
|
||||
captured.append((hook_name, kwargs))
|
||||
return []
|
||||
|
||||
notify_seen = threading.Event()
|
||||
|
||||
def notify_cb(approval_data):
|
||||
notify_seen.set()
|
||||
|
||||
register_gateway_notify(isolated_session, notify_cb)
|
||||
result_holder = {}
|
||||
|
||||
def run_guard():
|
||||
with patch("hermes_cli.plugins.invoke_hook", side_effect=fake_invoke_hook):
|
||||
result_holder["result"] = check_all_command_guards(
|
||||
"rm -rf /tmp/test-gateway-timeout", "local",
|
||||
)
|
||||
|
||||
t = threading.Thread(target=run_guard, daemon=True)
|
||||
t.start()
|
||||
assert notify_seen.wait(timeout=5)
|
||||
# Deliberately do NOT resolve -- let it time out
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive()
|
||||
unregister_gateway_notify(isolated_session)
|
||||
|
||||
assert result_holder["result"]["approved"] is False
|
||||
|
||||
post_kwargs = next(kw for name, kw in captured if name == "post_approval_response")
|
||||
assert post_kwargs["choice"] == "timeout"
|
||||
@@ -568,163 +568,6 @@ 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"]:
|
||||
|
||||
@@ -30,32 +30,6 @@ _approval_session_key: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||
)
|
||||
|
||||
|
||||
def _fire_approval_hook(hook_name: str, **kwargs) -> None:
|
||||
"""Invoke a plugin lifecycle hook for the approval system.
|
||||
|
||||
Lazy-imports the plugin manager to avoid circular imports (approval.py is
|
||||
imported very early, long before plugins are discovered). Never raises --
|
||||
plugin errors are logged and swallowed.
|
||||
|
||||
Only fires for the two approval-specific hooks in VALID_HOOKS:
|
||||
pre_approval_request, post_approval_response.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook
|
||||
except Exception:
|
||||
# Plugin system not available in this execution context
|
||||
# (e.g. bare tool-only imports, minimal test environments).
|
||||
return
|
||||
try:
|
||||
invoke_hook(hook_name, **kwargs)
|
||||
except Exception as exc:
|
||||
# invoke_hook() already swallows per-callback errors, so reaching here
|
||||
# means the dispatch layer itself failed. Log and move on -- approval
|
||||
# flow is safety-critical, plugin observability is not.
|
||||
logger.debug("Approval hook %s dispatch failed: %s", hook_name, exc)
|
||||
|
||||
|
||||
|
||||
def set_current_session_key(session_key: str) -> contextvars.Token[str]:
|
||||
"""Bind the active approval session key to the current context."""
|
||||
return _approval_session_key.set(session_key or "")
|
||||
@@ -1028,19 +1002,6 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
with _lock:
|
||||
_gateway_queues.setdefault(session_key, []).append(entry)
|
||||
|
||||
# Notify plugins that an approval is being requested. Fires before
|
||||
# the gateway notify callback so observers (e.g. macOS notifier
|
||||
# plugins, audit logs, Slack alerts) get the event in real time.
|
||||
_fire_approval_hook(
|
||||
"pre_approval_request",
|
||||
command=command,
|
||||
description=combined_desc,
|
||||
pattern_key=primary_key,
|
||||
pattern_keys=list(all_keys),
|
||||
session_key=session_key,
|
||||
surface="gateway",
|
||||
)
|
||||
|
||||
# Notify the user (bridges sync agent thread → async gateway)
|
||||
try:
|
||||
notify_cb(approval_data)
|
||||
@@ -1106,24 +1067,6 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
_gateway_queues.pop(session_key, None)
|
||||
|
||||
choice = entry.result
|
||||
# Normalize outcome for the post hook. Unresolved (timeout) and
|
||||
# None both mean the user never responded; report that explicitly
|
||||
# so plugins can distinguish timeout from explicit deny.
|
||||
_outcome = (
|
||||
"timeout" if not resolved
|
||||
else (choice if choice else "timeout")
|
||||
)
|
||||
_fire_approval_hook(
|
||||
"post_approval_response",
|
||||
command=command,
|
||||
description=combined_desc,
|
||||
pattern_key=primary_key,
|
||||
pattern_keys=list(all_keys),
|
||||
session_key=session_key,
|
||||
surface="gateway",
|
||||
choice=_outcome,
|
||||
)
|
||||
|
||||
if not resolved or choice is None or choice == "deny":
|
||||
reason = "timed out" if not resolved else "denied by user"
|
||||
return {
|
||||
@@ -1168,28 +1111,9 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
|
||||
# CLI interactive: single combined prompt
|
||||
# Hide [a]lways when any tirith warning is present
|
||||
_fire_approval_hook(
|
||||
"pre_approval_request",
|
||||
command=command,
|
||||
description=combined_desc,
|
||||
pattern_key=primary_key,
|
||||
pattern_keys=list(all_keys),
|
||||
session_key=session_key,
|
||||
surface="cli",
|
||||
)
|
||||
choice = prompt_dangerous_approval(command, combined_desc,
|
||||
allow_permanent=not has_tirith,
|
||||
approval_callback=approval_callback)
|
||||
_fire_approval_hook(
|
||||
"post_approval_response",
|
||||
command=command,
|
||||
description=combined_desc,
|
||||
pattern_key=primary_key,
|
||||
pattern_keys=list(all_keys),
|
||||
session_key=session_key,
|
||||
surface="cli",
|
||||
choice=choice,
|
||||
)
|
||||
|
||||
if choice == "deny":
|
||||
return {
|
||||
|
||||
@@ -1616,19 +1616,6 @@ 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.")
|
||||
@@ -2125,20 +2112,8 @@ 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:
|
||||
@@ -2153,28 +2128,6 @@ 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(
|
||||
|
||||
+74
-3
@@ -78,7 +78,14 @@ const CHAT_NAV_ITEM: NavItem = {
|
||||
icon: Terminal,
|
||||
};
|
||||
|
||||
/** Built-in routes except /chat (only with `hermes dashboard --tui`). */
|
||||
/**
|
||||
* Built-in routes except /chat. Chat is rendered persistently (outside
|
||||
* <Routes>) when embedded — see ChatPageHost below — so the PTY child,
|
||||
* WebSocket, and xterm instance survive when the user visits another tab
|
||||
* and comes back. A `display:none` toggle hides the terminal without
|
||||
* unmounting. Routing still owns the URL so /chat deep-links, browser
|
||||
* back/forward, and nav highlight keep working.
|
||||
*/
|
||||
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
@@ -91,6 +98,14 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/docs": DocsPage,
|
||||
};
|
||||
|
||||
// Route placeholder for /chat. The persistent ChatPage host (rendered
|
||||
// outside <Routes> when embedded chat is on) paints on top; this empty
|
||||
// element just claims the path so the `*` catch-all redirect doesn't
|
||||
// fire when the user navigates to /chat.
|
||||
function ChatRouteSink() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const BUILTIN_NAV_REST: NavItem[] = [
|
||||
{
|
||||
path: "/sessions",
|
||||
@@ -240,7 +255,7 @@ function buildRoutes(
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
const { manifests } = usePlugins();
|
||||
const { manifests, loading: pluginsLoading } = usePlugins();
|
||||
const { theme } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
||||
@@ -249,10 +264,32 @@ export default function App() {
|
||||
const isChatRoute = normalizedPath === "/chat";
|
||||
const embeddedChat = isDashboardEmbeddedChatEnabled();
|
||||
|
||||
// A plugin can replace the built-in /chat page via `tab.override: "/chat"`
|
||||
// in its manifest. When one does, `buildRoutes` already swaps the route
|
||||
// element for <PluginPage /> — but we also have to suppress the
|
||||
// persistent ChatPage host below, or the plugin's page and the built-in
|
||||
// terminal would paint on top of each other. The override is niche
|
||||
// (nothing ships overriding /chat today) but it's an advertised
|
||||
// extension point, so preserve the pre-persistence contract: when a
|
||||
// plugin owns /chat, the built-in chat UI is entirely absent.
|
||||
//
|
||||
// Waiting on `pluginsLoading` is load-bearing: manifests arrive
|
||||
// asynchronously from /api/dashboard/plugins, so on initial render
|
||||
// `chatOverriddenByPlugin` is always false. Without the loading
|
||||
// gate, the persistent host would mount, spawn a PTY, and THEN get
|
||||
// yanked out from under the user when the plugin's manifest resolves
|
||||
// — killing the session mid-paint. Delaying host mount by the
|
||||
// plugin-load window (typically <50ms, worst case 2s safety timeout)
|
||||
// is the cheaper trade-off.
|
||||
const chatOverriddenByPlugin = useMemo(
|
||||
() => manifests.some((m) => m.tab.override === "/chat"),
|
||||
[manifests],
|
||||
);
|
||||
|
||||
const builtinRoutes = useMemo(
|
||||
() => ({
|
||||
...BUILTIN_ROUTES_CORE,
|
||||
...(embeddedChat ? { "/chat": ChatPage } : {}),
|
||||
...(embeddedChat ? { "/chat": ChatRouteSink } : {}),
|
||||
}),
|
||||
[embeddedChat],
|
||||
);
|
||||
@@ -519,6 +556,40 @@ export default function App() {
|
||||
element={<Navigate to="/sessions" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
{/*
|
||||
Persistent chat host: always mounted when `hermes dashboard
|
||||
--tui` is active, visibility toggled by route. Keeping the
|
||||
tree alive preserves the xterm instance, its WebSocket, and
|
||||
the PTY child that backs the TUI session — so navigating to
|
||||
another tab and returning lands the user in the same
|
||||
conversation instead of spawning a fresh session.
|
||||
|
||||
The host sits alongside <Routes> (not inside one) because
|
||||
React Router unmounts route elements on path change, which
|
||||
is exactly the destructive lifecycle we're avoiding.
|
||||
|
||||
Trade-off worth knowing about: while hidden, ChatPage still
|
||||
holds a PTY child + WebSocket + xterm instance for the
|
||||
dashboard's full lifetime. The WS keeps delivering bytes
|
||||
and xterm keeps parsing them into a display:none host
|
||||
(cheap — no paint work, but not free). If this becomes a
|
||||
resource problem we can pause `term.write` when !isActive
|
||||
or idle-disconnect after N minutes hidden; neither is
|
||||
shipped today.
|
||||
*/}
|
||||
{embeddedChat && !pluginsLoading && !chatOverriddenByPlugin && (
|
||||
<div
|
||||
data-chat-active={isChatRoute ? "true" : "false"}
|
||||
className={cn(
|
||||
"min-h-0 min-w-0",
|
||||
isChatRoute ? "flex flex-1 flex-col" : "hidden",
|
||||
)}
|
||||
aria-hidden={!isChatRoute}
|
||||
>
|
||||
<ChatPage isActive={isChatRoute} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot name="post-main" />
|
||||
</div>
|
||||
|
||||
@@ -237,9 +237,6 @@ 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,9 +242,6 @@ export interface Translations {
|
||||
exportConfig: string;
|
||||
importConfig: string;
|
||||
resetDefaults: string;
|
||||
resetScopeTooltip: string;
|
||||
confirmResetScope: string;
|
||||
resetScopeToast: string;
|
||||
rawYaml: string;
|
||||
searchResults: string;
|
||||
fields: string;
|
||||
|
||||
@@ -234,9 +234,6 @@ export const zh: Translations = {
|
||||
exportConfig: "导出配置为 JSON",
|
||||
importConfig: "从 JSON 导入配置",
|
||||
resetDefaults: "恢复默认值",
|
||||
resetScopeTooltip: "将{scope}恢复为默认值",
|
||||
confirmResetScope: "确定要将{scope}的所有设置恢复为默认值吗?此操作仅更新表单,在按下「保存」按钮前不会写入 config.yaml。",
|
||||
resetScopeToast: "{scope}已恢复为默认值 — 请检查并保存以生效",
|
||||
rawYaml: "原始 YAML 配置",
|
||||
searchResults: "搜索结果",
|
||||
fields: "个字段",
|
||||
|
||||
@@ -101,11 +101,15 @@ function terminalLineHeightForWidth(layoutWidthPx: number): number {
|
||||
return layoutWidthPx < 1024 ? 1.02 : 1.15;
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
// Exposed to the main metrics-sync effect so it can refit the terminal
|
||||
// the moment `isActive` flips back to true (display:none → display:flex
|
||||
// collapses the host's box, so ResizeObserver never fires on return).
|
||||
const syncMetricsRef = useRef<(() => void) | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
// Lazy-init: the missing-token check happens at construction so the effect
|
||||
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||
@@ -116,7 +120,16 @@ export default function ChatPage() {
|
||||
);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
|
||||
// Raw state for the mobile side-sheet + a derived value that force-
|
||||
// closes whenever the chat tab isn't active. The *derived* value is
|
||||
// what side-effects (body-scroll lock, keydown listener, portal render)
|
||||
// key on — that way switching to another tab triggers the effect's
|
||||
// cleanup, releasing the scroll-lock on /sessions etc. Returning to
|
||||
// /chat re-runs the effect (derived flips back to true) and re-locks.
|
||||
// Keying on the raw state would leak the body.overflow="hidden" across
|
||||
// tabs because the dep wouldn't change on tab switch.
|
||||
const [mobilePanelOpenRaw, setMobilePanelOpen] = useState(false);
|
||||
const mobilePanelOpen = isActive && mobilePanelOpenRaw;
|
||||
const { setEnd } = usePageHeader();
|
||||
const { t } = useI18n();
|
||||
const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []);
|
||||
@@ -168,6 +181,12 @@ export default function ChatPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// When hidden (non-chat tab) we must not register the header button —
|
||||
// another page owns the header's end slot at that point.
|
||||
if (!isActive) {
|
||||
setEnd(null);
|
||||
return;
|
||||
}
|
||||
if (!narrow) {
|
||||
setEnd(null);
|
||||
return;
|
||||
@@ -191,7 +210,7 @@ export default function ChatPage() {
|
||||
</button>,
|
||||
);
|
||||
return () => setEnd(null);
|
||||
}, [narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
||||
}, [isActive, narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
|
||||
|
||||
const handleCopyLast = () => {
|
||||
const ws = wsRef.current;
|
||||
@@ -392,6 +411,12 @@ export default function ChatPage() {
|
||||
|
||||
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
const syncTerminalMetrics = () => {
|
||||
// display:none hosts have clientWidth/Height = 0, which fit() turns
|
||||
// into a 1x1 terminal. Skip entirely while hidden; the visibility
|
||||
// effect below runs another fit as soon as the tab is shown again.
|
||||
if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const w = terminalTierWidthPx(host);
|
||||
const nextSize = terminalFontSizeForWidth(w);
|
||||
const nextLh = terminalLineHeightForWidth(w);
|
||||
@@ -422,6 +447,7 @@ export default function ChatPage() {
|
||||
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
|
||||
}
|
||||
};
|
||||
syncMetricsRef.current = syncTerminalMetrics;
|
||||
|
||||
const scheduleSyncTerminalMetrics = () => {
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
@@ -565,6 +591,7 @@ export default function ChatPage() {
|
||||
|
||||
return () => {
|
||||
unmounting = true;
|
||||
syncMetricsRef.current = null;
|
||||
onDataDisposable.dispose();
|
||||
onResizeDisposable.dispose();
|
||||
if (metricsDebounce) clearTimeout(metricsDebounce);
|
||||
@@ -593,6 +620,51 @@ export default function ChatPage() {
|
||||
};
|
||||
}, [channel]);
|
||||
|
||||
// When the user returns to the chat tab (isActive: false → true), the
|
||||
// terminal host just transitioned from display:none to display:flex.
|
||||
// ResizeObserver won't fire on that kind of style-driven box change —
|
||||
// xterm thinks its grid is still whatever it was when the tab was
|
||||
// hidden (or 0×0, if it was hidden before first fit). Force a refit
|
||||
// after two animation frames so layout has committed.
|
||||
//
|
||||
// Focus handling: we only steal focus back into the terminal when
|
||||
// nothing else inside ChatPage was holding it (typically the first
|
||||
// activation after mount, where document.activeElement is <body>; or
|
||||
// a return after the user had been typing in the terminal, where
|
||||
// focus was already on the xterm textarea before the tab got hidden
|
||||
// and has since fallen back to <body>). If the user had clicked
|
||||
// into the sidebar (model picker, tool-call entry) before switching
|
||||
// tabs, we must not yank focus away from wherever they left it when
|
||||
// they come back — that's a surprise and an a11y foot-gun.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
let raf1 = 0;
|
||||
let raf2 = 0;
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
raf1 = 0;
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
raf2 = 0;
|
||||
syncMetricsRef.current?.();
|
||||
const host = hostRef.current;
|
||||
const active = typeof document !== "undefined"
|
||||
? document.activeElement
|
||||
: null;
|
||||
const focusIsElsewhereInChatPage =
|
||||
active !== null &&
|
||||
active !== document.body &&
|
||||
host !== null &&
|
||||
!host.contains(active);
|
||||
if (!focusIsElsewhereInChatPage) {
|
||||
termRef.current?.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (raf1) cancelAnimationFrame(raf1);
|
||||
if (raf2) cancelAnimationFrame(raf2);
|
||||
};
|
||||
}, [isActive]);
|
||||
|
||||
// Layout:
|
||||
// outer flex column — sits inside the dashboard's content area
|
||||
// row split — terminal pane (flex-1) + sidebar (fixed width, lg+)
|
||||
@@ -612,6 +684,7 @@ export default function ChatPage() {
|
||||
// dashboard column uses `relative z-2`, which traps `position:fixed`
|
||||
// descendants below those layers (see Toast.tsx).
|
||||
const mobileModelToolsPortal =
|
||||
isActive &&
|
||||
narrow &&
|
||||
portalRoot &&
|
||||
createPortal(
|
||||
|
||||
@@ -228,26 +228,7 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
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");
|
||||
if (defaults) setConfig(structuredClone(defaults));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
@@ -352,17 +333,9 @@ export default function ConfigPage() {
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
{!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>
|
||||
);
|
||||
})()}
|
||||
<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>
|
||||
|
||||
<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: false # Redact API key patterns in tool output and logs (off by default)
|
||||
redact_secrets: true # Redact API key patterns in tool output and logs
|
||||
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` — 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.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
@@ -248,8 +248,6 @@ def register(ctx):
|
||||
| [`on_session_reset`](#on_session_reset) | Gateway swaps in a fresh session key (e.g. `/new`, `/reset`) | ignored |
|
||||
| [`subagent_stop`](#subagent_stop) | A `delegate_task` child has exited | ignored |
|
||||
| [`pre_gateway_dispatch`](#pre_gateway_dispatch) | Gateway received a user message, before auth + dispatch | `{"action": "skip" \| "rewrite" \| "allow", ...}` to influence flow |
|
||||
| [`pre_approval_request`](#pre_approval_request) | Dangerous command needs user approval, before the prompt/notification is sent | ignored |
|
||||
| [`post_approval_response`](#post_approval_response) | User responded to an approval prompt (or it timed out) | ignored |
|
||||
|
||||
---
|
||||
|
||||
@@ -777,97 +775,6 @@ def register(ctx):
|
||||
|
||||
---
|
||||
|
||||
### `pre_approval_request`
|
||||
|
||||
Fires **immediately before** an approval request is shown to the user — covers every surface: interactive CLI, the Ink TUI, gateway platforms (Telegram, Discord, Slack, WhatsApp, Matrix, etc.), and ACP clients (VS Code, Zed, JetBrains).
|
||||
|
||||
This is the right place to wire a custom notifier — for example, a macOS menu-bar app that pops an allow/deny notification, or an audit log that records every approval request with context.
|
||||
|
||||
**Callback signature:**
|
||||
|
||||
```python
|
||||
def my_callback(
|
||||
command: str,
|
||||
description: str,
|
||||
pattern_key: str,
|
||||
pattern_keys: list[str],
|
||||
session_key: str,
|
||||
surface: str,
|
||||
**kwargs,
|
||||
):
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `command` | `str` | The shell command awaiting approval |
|
||||
| `description` | `str` | Human-readable reason(s) the command is flagged (combined when multiple patterns match) |
|
||||
| `pattern_key` | `str` | Primary pattern key that triggered the approval (e.g. `"rm_rf"`, `"sudo"`) |
|
||||
| `pattern_keys` | `list[str]` | All pattern keys that matched |
|
||||
| `session_key` | `str` | Session identifier, useful for scoping notifications per-chat |
|
||||
| `surface` | `str` | `"cli"` for interactive CLI/TUI prompts, `"gateway"` for async platform approvals |
|
||||
|
||||
**Return value:** ignored. Hooks here are observer-only; they cannot veto or pre-answer the approval. Use [`pre_tool_call`](#pre_tool_call) to block a tool before it reaches the approval system.
|
||||
|
||||
**Use cases:** Desktop notifications, push alerts, audit logging, Slack webhooks, escalation routing, metrics.
|
||||
|
||||
**Example — desktop notification on macOS:**
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
|
||||
def notify_approval(command, description, session_key, **kwargs):
|
||||
title = "Hermes needs approval"
|
||||
body = f"{description}: {command[:80]}"
|
||||
subprocess.Popen([
|
||||
"osascript", "-e",
|
||||
f'display notification "{body}" with title "{title}"',
|
||||
])
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("pre_approval_request", notify_approval)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `post_approval_response`
|
||||
|
||||
Fires **after** the user responds to an approval prompt (or the prompt times out).
|
||||
|
||||
**Callback signature:**
|
||||
|
||||
```python
|
||||
def my_callback(
|
||||
command: str,
|
||||
description: str,
|
||||
pattern_key: str,
|
||||
pattern_keys: list[str],
|
||||
session_key: str,
|
||||
surface: str,
|
||||
choice: str,
|
||||
**kwargs,
|
||||
):
|
||||
```
|
||||
|
||||
Same kwargs as `pre_approval_request`, plus:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `choice` | `str` | One of `"once"`, `"session"`, `"always"`, `"deny"`, or `"timeout"` |
|
||||
|
||||
**Return value:** ignored.
|
||||
|
||||
**Use cases:** Close the matching desktop notification, record the final decision in an audit log, update metrics, roll forward a rate limiter.
|
||||
|
||||
```python
|
||||
def log_decision(command, choice, session_key, **kwargs):
|
||||
logger.info("approval %s: %s for session %s", choice, command[:60], session_key)
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("post_approval_response", log_decision)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Hooks
|
||||
|
||||
Declare shell-script hooks in your `cli-config.yaml` and Hermes will run them as subprocesses whenever the corresponding plugin-hook event fires — in both CLI and gateway sessions. No Python plugin authoring required.
|
||||
|
||||
Reference in New Issue
Block a user