Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7484893c4e | |||
| 0b649e4bb5 | |||
| e563186e99 | |||
| 0e315a6f02 | |||
| 6d2fa03837 | |||
| f3ae1d765d | |||
| 49da1ff1b1 | |||
| 76a1e6e0fe | |||
| 21bb2547c6 | |||
| 58413c411f | |||
| cc12ab8290 | |||
| 74e883ca37 | |||
| e376a9b2c9 | |||
| 2629927032 | |||
| aedf6c7964 | |||
| 5a1cce53e4 | |||
| 419b719c2b | |||
| f3fb3eded4 | |||
| d7164603da | |||
| e683c9db90 | |||
| 7663c98c1e | |||
| 714809634f | |||
| f4c7086035 | |||
| 0b143f2ea3 | |||
| c8e4dcf412 | |||
| 00dd5cc491 | |||
| 9bb8cb8d83 | |||
| 5dea7e1ebc | |||
| b1e2b5ea74 | |||
| 96f9b91489 | |||
| bb3a4fc68e | |||
| 429da6cbce | |||
| 4f2f09affa | |||
| af7d809354 | |||
| fbfa7c27d5 | |||
| 1bcc87a153 | |||
| 437feabb74 | |||
| 957485876b | |||
| c6c769772f | |||
| f63cc3c0c7 | |||
| cff9b7ffab | |||
| 96c060018a | |||
| 04baab5422 | |||
| 9a0dfb5a6d | |||
| 68528068ec | |||
| 8dd738c2e6 | |||
| 0f597dd127 | |||
| 5a8b5f149d | |||
| f4f8b9579e | |||
| c6ff5e5d30 | |||
| 9aedab00f4 | |||
| 19292eb8bf | |||
| 6d5f607e48 | |||
| 52bd3bd200 | |||
| 568be71003 | |||
| a2f46e4665 | |||
| 7d426e6536 | |||
| 30ae68dd33 | |||
| 9afe1784bd | |||
| 94f5979cc2 | |||
| 738f0bac13 | |||
| 37bb4f807b | |||
| b577697189 | |||
| 5b22e61cfa | |||
| b39ea46488 | |||
| aad40f6d0c | |||
| 41c233cb99 | |||
| 1f1f297528 | |||
| 1495647636 | |||
| 4e78963fe8 | |||
| f92298fe95 | |||
| eaa21a8275 | |||
| a420235b66 | |||
| 6c3565df57 | |||
| 51d826f889 | |||
| a04854800f | |||
| 940237c6fd | |||
| 95ee453bc0 | |||
| 38cce22e2c | |||
| 7368854398 | |||
| 38ccd9eb95 | |||
| 45034b746f | |||
| a7588830d4 | |||
| 9431f82aff | |||
| 6da952bc50 | |||
| 8779a268a7 | |||
| 0848a79476 | |||
| 871313ae2d | |||
| 13d7ff3420 | |||
| d5023d36d8 | |||
| 0602ff8f58 | |||
| 8104f400f8 | |||
| 1ed00496f2 | |||
| f92a0b8596 | |||
| 1723e8e998 | |||
| 07148cac9a | |||
| 0fc0c1c83b | |||
| 5075717949 | |||
| f783986f5a | |||
| bda9aa17cb | |||
| 8394b5ddd2 | |||
| d416a69288 | |||
| 4caa635803 | |||
| a64d8a83e1 | |||
| dfde4058cf | |||
| 13b3ea6484 | |||
| b87d00288d | |||
| 08e2a1a51e | |||
| 9634e20e15 | |||
| 2d0d05a337 | |||
| 3b554bf839 | |||
| 69a0092c38 | |||
| c3141429b7 | |||
| 769ec1ee1a | |||
| 3237733ca5 | |||
| 54d5138a54 | |||
| 6dcb3c4774 | |||
| 096b3f9f12 | |||
| a3aed1bd26 | |||
| 4970705ed3 | |||
| 2194425918 | |||
| 3878495972 | |||
| 4e40e93b98 | |||
| 122925a6f2 | |||
| e79cc88985 | |||
| e053433c84 |
+2
-1
@@ -13,7 +13,8 @@ COPY . /opt/hermes
|
||||
WORKDIR /opt/hermes
|
||||
|
||||
# Install Python and Node dependencies in one layer, no cache
|
||||
RUN pip install --no-cache-dir -e ".[all]" --break-system-packages && \
|
||||
RUN pip install --no-cache-dir uv --break-system-packages && \
|
||||
uv pip install --system --break-system-packages --no-cache -e ".[all]" && \
|
||||
npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
cd /opt/hermes/scripts/whatsapp-bridge && \
|
||||
|
||||
@@ -33,8 +33,10 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Works on Linux, macOS, and WSL2. The installer handles everything — Python, Node.js, dependencies, and the `hermes` command. No prerequisites except git.
|
||||
Works on Linux, macOS, WSL2, and Android via Termux. The installer handles the platform-specific setup for you.
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run the command above.
|
||||
|
||||
After installation:
|
||||
|
||||
@@ -36,6 +36,7 @@ from acp.schema import (
|
||||
SessionCapabilities,
|
||||
SessionForkCapabilities,
|
||||
SessionListCapabilities,
|
||||
SessionResumeCapabilities,
|
||||
SessionInfo,
|
||||
TextContentBlock,
|
||||
UnstructuredCommandInput,
|
||||
@@ -245,9 +246,11 @@ class HermesACPAgent(acp.Agent):
|
||||
protocol_version=acp.PROTOCOL_VERSION,
|
||||
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
|
||||
agent_capabilities=AgentCapabilities(
|
||||
load_session=True,
|
||||
session_capabilities=SessionCapabilities(
|
||||
fork=SessionForkCapabilities(),
|
||||
list=SessionListCapabilities(),
|
||||
resume=SessionResumeCapabilities(),
|
||||
),
|
||||
),
|
||||
auth_methods=auth_methods,
|
||||
@@ -451,14 +454,13 @@ class HermesACPAgent(acp.Agent):
|
||||
await conn.session_update(session_id, update)
|
||||
|
||||
usage = None
|
||||
usage_data = result.get("usage")
|
||||
if usage_data and isinstance(usage_data, dict):
|
||||
if any(result.get(key) is not None for key in ("prompt_tokens", "completion_tokens", "total_tokens")):
|
||||
usage = Usage(
|
||||
input_tokens=usage_data.get("prompt_tokens", 0),
|
||||
output_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
thought_tokens=usage_data.get("reasoning_tokens"),
|
||||
cached_read_tokens=usage_data.get("cached_tokens"),
|
||||
input_tokens=result.get("prompt_tokens", 0),
|
||||
output_tokens=result.get("completion_tokens", 0),
|
||||
total_tokens=result.get("total_tokens", 0),
|
||||
thought_tokens=result.get("reasoning_tokens"),
|
||||
cached_read_tokens=result.get("cache_read_tokens"),
|
||||
)
|
||||
|
||||
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
|
||||
|
||||
@@ -74,8 +74,11 @@ def _get_anthropic_max_output(model: str) -> int:
|
||||
model IDs (claude-sonnet-4-5-20250929) and variant suffixes (:1m, :fast)
|
||||
resolve correctly. Longest-prefix match wins to avoid e.g. "claude-3-5"
|
||||
matching before "claude-3-5-sonnet".
|
||||
|
||||
Normalizes dots to hyphens so that model names like
|
||||
``anthropic/claude-opus-4.6`` match the ``claude-opus-4-6`` table key.
|
||||
"""
|
||||
m = model.lower()
|
||||
m = model.lower().replace(".", "-")
|
||||
best_key = ""
|
||||
best_val = _ANTHROPIC_DEFAULT_OUTPUT_LIMIT
|
||||
for key, val in _ANTHROPIC_OUTPUT_LIMITS.items():
|
||||
@@ -95,6 +98,15 @@ _COMMON_BETAS = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
]
|
||||
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
|
||||
# the fine-grained tool streaming beta is present. Omit it so tool calls
|
||||
# fall back to the provider's default response path.
|
||||
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||||
|
||||
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
|
||||
# significantly higher output token throughput on Opus 4.6 (~2.5x).
|
||||
# See https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
||||
_FAST_MODE_BETA = "fast-mode-2026-02-01"
|
||||
|
||||
# Additional beta headers required for OAuth/subscription auth.
|
||||
# Matches what Claude Code (and pi-ai / OpenCode) send.
|
||||
@@ -204,6 +216,19 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
|
||||
|
||||
|
||||
def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
"""Return the beta headers that are safe for the configured endpoint.
|
||||
|
||||
MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests
|
||||
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
|
||||
tool-use message triggers a connection error. Strip that beta for
|
||||
Bearer-auth endpoints while keeping all other betas intact.
|
||||
"""
|
||||
if _requires_bearer_auth(base_url):
|
||||
return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
@@ -222,6 +247,7 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
}
|
||||
if normalized_base_url:
|
||||
kwargs["base_url"] = normalized_base_url
|
||||
common_betas = _common_betas_for_base_url(normalized_base_url)
|
||||
|
||||
if _requires_bearer_auth(normalized_base_url):
|
||||
# Some Anthropic-compatible providers (e.g. MiniMax) expect the API key in
|
||||
@@ -231,21 +257,21 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
# not use Anthropic's sk-ant-api prefix and would otherwise be misread as
|
||||
# Anthropic OAuth/setup tokens.
|
||||
kwargs["auth_token"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
elif _is_third_party_anthropic_endpoint(base_url):
|
||||
# Third-party proxies (Azure AI Foundry, AWS Bedrock, etc.) use their
|
||||
# own API keys with x-api-key auth. Skip OAuth detection — their keys
|
||||
# don't follow Anthropic's sk-ant-* prefix convention and would be
|
||||
# misclassified as OAuth tokens.
|
||||
kwargs["api_key"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
elif _is_oauth_token(api_key):
|
||||
# OAuth access token / setup-token → Bearer auth + Claude Code identity.
|
||||
# Anthropic routes OAuth requests based on user-agent and headers;
|
||||
# without Claude Code's fingerprint, requests get intermittent 500s.
|
||||
all_betas = _COMMON_BETAS + _OAUTH_ONLY_BETAS
|
||||
all_betas = common_betas + _OAUTH_ONLY_BETAS
|
||||
kwargs["auth_token"] = api_key
|
||||
kwargs["default_headers"] = {
|
||||
"anthropic-beta": ",".join(all_betas),
|
||||
@@ -255,8 +281,8 @@ def build_anthropic_client(api_key: str, base_url: str = None):
|
||||
else:
|
||||
# Regular API key → x-api-key header + common betas
|
||||
kwargs["api_key"] = api_key
|
||||
if _COMMON_BETAS:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(_COMMON_BETAS)}
|
||||
if common_betas:
|
||||
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
|
||||
|
||||
return _anthropic_sdk.Anthropic(**kwargs)
|
||||
|
||||
@@ -1158,6 +1184,7 @@ def build_anthropic_kwargs(
|
||||
preserve_dots: bool = False,
|
||||
context_length: Optional[int] = None,
|
||||
base_url: str | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build kwargs for anthropic.messages.create().
|
||||
|
||||
@@ -1191,6 +1218,10 @@ def build_anthropic_kwargs(
|
||||
|
||||
When *base_url* points to a third-party Anthropic-compatible endpoint,
|
||||
thinking block signatures are stripped (they are Anthropic-proprietary).
|
||||
|
||||
When *fast_mode* is True, adds ``speed: "fast"`` and the fast-mode beta
|
||||
header for ~2.5x faster output throughput on Opus 4.6. Currently only
|
||||
supported on native Anthropic endpoints (not third-party compatible ones).
|
||||
"""
|
||||
system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url)
|
||||
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
|
||||
@@ -1289,6 +1320,20 @@ def build_anthropic_kwargs(
|
||||
kwargs["temperature"] = 1
|
||||
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
|
||||
|
||||
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
|
||||
# Adds speed:"fast" + the fast-mode beta header for ~2.5x output speed.
|
||||
# Only for native Anthropic endpoints — third-party providers would
|
||||
# reject the unknown beta header and speed parameter.
|
||||
if fast_mode and not _is_third_party_anthropic_endpoint(base_url):
|
||||
kwargs["speed"] = "fast"
|
||||
# Build extra_headers with ALL applicable betas (the per-request
|
||||
# extra_headers override the client-level anthropic-beta header).
|
||||
betas = list(_common_betas_for_base_url(base_url))
|
||||
if is_oauth:
|
||||
betas.extend(_OAUTH_ONLY_BETAS)
|
||||
betas.append(_FAST_MODE_BETA)
|
||||
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
@@ -1350,4 +1395,4 @@ def normalize_anthropic_response(
|
||||
reasoning_details=reasoning_details or None,
|
||||
),
|
||||
finish_reason,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -687,6 +687,15 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
if pconfig.auth_type != "api_key":
|
||||
continue
|
||||
if provider_id == "anthropic":
|
||||
# Only try anthropic when the user has explicitly configured it.
|
||||
# Without this gate, Claude Code credentials get silently used
|
||||
# as auxiliary fallback when the user's primary provider fails.
|
||||
try:
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
if not is_provider_explicitly_configured("anthropic"):
|
||||
continue
|
||||
except ImportError:
|
||||
pass
|
||||
return _try_anthropic()
|
||||
|
||||
pool_present, entry = _select_pool_entry(provider_id)
|
||||
@@ -702,7 +711,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
|
||||
extra = {}
|
||||
if "api.kimi.com" in base_url.lower():
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
|
||||
elif "api.githubcopilot.com" in base_url.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
@@ -721,7 +730,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
|
||||
extra = {}
|
||||
if "api.kimi.com" in base_url.lower():
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
|
||||
elif "api.githubcopilot.com" in base_url.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
@@ -1161,7 +1170,7 @@ def _to_async_client(sync_client, model: str):
|
||||
|
||||
async_kwargs["default_headers"] = copilot_default_headers()
|
||||
elif "api.kimi.com" in base_lower:
|
||||
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
|
||||
return AsyncOpenAI(**async_kwargs), model
|
||||
|
||||
|
||||
@@ -1283,7 +1292,7 @@ def resolve_provider_client(
|
||||
final_model = model or _read_main_model() or "gpt-4o-mini"
|
||||
extra = {}
|
||||
if "api.kimi.com" in custom_base.lower():
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
|
||||
elif "api.githubcopilot.com" in custom_base.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
@@ -1366,7 +1375,7 @@ def resolve_provider_client(
|
||||
# Provider-specific headers
|
||||
headers = {}
|
||||
if "api.kimi.com" in base_url.lower():
|
||||
headers["User-Agent"] = "KimiCLI/1.3"
|
||||
headers["User-Agent"] = "KimiCLI/1.30.0"
|
||||
elif "api.githubcopilot.com" in base_url.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from hermes_cli.auth import (
|
||||
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
KIMI_CODE_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
_auth_store_lock,
|
||||
_codex_access_token_is_expiring,
|
||||
_decode_jwt_claims,
|
||||
_import_codex_cli_tokens,
|
||||
@@ -27,6 +28,8 @@ from hermes_cli.auth import (
|
||||
_load_provider_state,
|
||||
_resolve_kimi_base_url,
|
||||
_resolve_zai_base_url,
|
||||
_save_auth_store,
|
||||
_save_provider_state,
|
||||
read_credential_pool,
|
||||
write_credential_pool,
|
||||
)
|
||||
@@ -479,6 +482,67 @@ class CredentialPool:
|
||||
logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
|
||||
return entry
|
||||
|
||||
def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None:
|
||||
"""Write refreshed pool entry tokens back to auth.json providers.
|
||||
|
||||
After a pool-level refresh, the pool entry has fresh tokens but
|
||||
auth.json's ``providers.<id>`` still holds the pre-refresh state.
|
||||
On the next ``load_pool()``, ``_seed_from_singletons()`` reads that
|
||||
stale state and can overwrite the fresh pool entry — potentially
|
||||
re-seeding a consumed single-use refresh token.
|
||||
|
||||
Applies to any OAuth provider whose singleton lives in auth.json
|
||||
(currently Nous and OpenAI Codex).
|
||||
"""
|
||||
if entry.source != "device_code":
|
||||
return
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
if self.provider == "nous":
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
if state is None:
|
||||
return
|
||||
state["access_token"] = entry.access_token
|
||||
if entry.refresh_token:
|
||||
state["refresh_token"] = entry.refresh_token
|
||||
if entry.expires_at:
|
||||
state["expires_at"] = entry.expires_at
|
||||
if entry.agent_key:
|
||||
state["agent_key"] = entry.agent_key
|
||||
if entry.agent_key_expires_at:
|
||||
state["agent_key_expires_at"] = entry.agent_key_expires_at
|
||||
for extra_key in ("obtained_at", "expires_in", "agent_key_id",
|
||||
"agent_key_expires_in", "agent_key_reused",
|
||||
"agent_key_obtained_at"):
|
||||
val = entry.extra.get(extra_key)
|
||||
if val is not None:
|
||||
state[extra_key] = val
|
||||
if entry.inference_base_url:
|
||||
state["inference_base_url"] = entry.inference_base_url
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
|
||||
elif self.provider == "openai-codex":
|
||||
state = _load_provider_state(auth_store, "openai-codex")
|
||||
if not isinstance(state, dict):
|
||||
return
|
||||
tokens = state.get("tokens")
|
||||
if not isinstance(tokens, dict):
|
||||
return
|
||||
tokens["access_token"] = entry.access_token
|
||||
if entry.refresh_token:
|
||||
tokens["refresh_token"] = entry.refresh_token
|
||||
if entry.last_refresh:
|
||||
state["last_refresh"] = entry.last_refresh
|
||||
_save_provider_state(auth_store, "openai-codex", state)
|
||||
|
||||
else:
|
||||
return
|
||||
|
||||
_save_auth_store(auth_store)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
|
||||
|
||||
def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
|
||||
if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token:
|
||||
if force:
|
||||
@@ -513,6 +577,13 @@ class CredentialPool:
|
||||
except Exception as wexc:
|
||||
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
||||
elif self.provider == "openai-codex":
|
||||
# Proactively sync from ~/.codex/auth.json before refresh.
|
||||
# The Codex CLI (or another Hermes profile) may have already
|
||||
# consumed our refresh_token. Syncing first avoids a
|
||||
# "refresh_token_reused" error when the CLI has a newer pair.
|
||||
synced = self._sync_codex_entry_from_cli(entry)
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
refreshed = auth_mod.refresh_codex_oauth_pure(
|
||||
entry.access_token,
|
||||
entry.refresh_token,
|
||||
@@ -598,6 +669,37 @@ class CredentialPool:
|
||||
# Credentials file had a valid (non-expired) token — use it directly
|
||||
logger.debug("Credentials file has valid token, using without refresh")
|
||||
return synced
|
||||
# For openai-codex: the refresh_token may have been consumed by
|
||||
# the Codex CLI between our proactive sync and the refresh call.
|
||||
# Re-sync and retry once.
|
||||
if self.provider == "openai-codex":
|
||||
synced = self._sync_codex_entry_from_cli(entry)
|
||||
if synced.refresh_token != entry.refresh_token:
|
||||
logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json")
|
||||
try:
|
||||
refreshed = auth_mod.refresh_codex_oauth_pure(
|
||||
synced.access_token,
|
||||
synced.refresh_token,
|
||||
)
|
||||
updated = replace(
|
||||
synced,
|
||||
access_token=refreshed["access_token"],
|
||||
refresh_token=refreshed["refresh_token"],
|
||||
last_refresh=refreshed.get("last_refresh"),
|
||||
last_status=STATUS_OK,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
)
|
||||
self._replace_entry(synced, updated)
|
||||
self._persist()
|
||||
self._sync_device_code_entry_to_auth_store(updated)
|
||||
return updated
|
||||
except Exception as retry_exc:
|
||||
logger.debug("Codex retry refresh also failed: %s", retry_exc)
|
||||
elif not self._entry_needs_refresh(synced):
|
||||
logger.debug("Codex CLI has valid token, using without refresh")
|
||||
self._sync_device_code_entry_to_auth_store(synced)
|
||||
return synced
|
||||
self._mark_exhausted(entry, None)
|
||||
return None
|
||||
|
||||
@@ -612,6 +714,10 @@ class CredentialPool:
|
||||
)
|
||||
self._replace_entry(entry, updated)
|
||||
self._persist()
|
||||
# Sync refreshed tokens back to auth.json providers so that
|
||||
# _seed_from_singletons() on the next load_pool() sees fresh state
|
||||
# instead of re-seeding stale/consumed tokens.
|
||||
self._sync_device_code_entry_to_auth_store(updated)
|
||||
return updated
|
||||
|
||||
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
|
||||
@@ -953,6 +1059,17 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
auth_store = _load_auth_store()
|
||||
|
||||
if provider == "anthropic":
|
||||
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
|
||||
# when the user has explicitly configured anthropic as their provider.
|
||||
# Without this gate, auxiliary client fallback chains silently read
|
||||
# ~/.claude/.credentials.json without user consent. See PR #4210.
|
||||
try:
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
if not is_provider_explicitly_configured("anthropic"):
|
||||
return changed, active_sources
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials
|
||||
|
||||
for source_name, creds in (
|
||||
@@ -960,6 +1077,13 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
("claude_code", read_claude_code_credentials()),
|
||||
):
|
||||
if creds and creds.get("accessToken"):
|
||||
# Check if user explicitly removed this source
|
||||
try:
|
||||
from hermes_cli.auth import is_source_suppressed
|
||||
if is_source_suppressed(provider, source_name):
|
||||
continue
|
||||
except ImportError:
|
||||
pass
|
||||
active_sources.add(source_name)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
|
||||
@@ -667,6 +667,27 @@ def _classify_by_message(
|
||||
should_compress=True,
|
||||
)
|
||||
|
||||
# Usage-limit patterns need the same disambiguation as 402: some providers
|
||||
# surface "usage limit" errors without an HTTP status code. A transient
|
||||
# signal ("try again", "resets at", …) means it's a periodic quota, not
|
||||
# billing exhaustion.
|
||||
has_usage_limit = any(p in error_msg for p in _USAGE_LIMIT_PATTERNS)
|
||||
if has_usage_limit:
|
||||
has_transient_signal = any(p in error_msg for p in _USAGE_LIMIT_TRANSIENT_SIGNALS)
|
||||
if has_transient_signal:
|
||||
return result_fn(
|
||||
FailoverReason.rate_limit,
|
||||
retryable=True,
|
||||
should_rotate_credential=True,
|
||||
should_fallback=True,
|
||||
)
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
should_rotate_credential=True,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Billing patterns
|
||||
if any(p in error_msg for p in _BILLING_PATTERNS):
|
||||
return result_fn(
|
||||
@@ -694,11 +715,16 @@ def _classify_by_message(
|
||||
)
|
||||
|
||||
# Auth patterns
|
||||
# Auth errors should NOT be retried directly — the credential is invalid and
|
||||
# retrying with the same key will always fail. Set retryable=False so the
|
||||
# caller triggers credential rotation (should_rotate_credential=True) or
|
||||
# provider fallback rather than an immediate retry loop.
|
||||
if any(p in error_msg for p in _AUTH_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.auth,
|
||||
retryable=True,
|
||||
retryable=False,
|
||||
should_rotate_credential=True,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Model not found patterns
|
||||
|
||||
@@ -126,6 +126,21 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"minimax": 1048576,
|
||||
# GLM
|
||||
"glm": 202752,
|
||||
# xAI Grok — xAI /v1/models does not return context_length metadata,
|
||||
# so these hardcoded fallbacks prevent Hermes from probing-down to
|
||||
# the default 128k when the user points at https://api.x.ai/v1
|
||||
# via a custom provider. Values sourced from models.dev (2026-04).
|
||||
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
||||
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
|
||||
"grok-code-fast": 256000, # grok-code-fast-1
|
||||
"grok-4-1-fast": 2000000, # grok-4-1-fast-(non-)reasoning
|
||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning
|
||||
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
|
||||
"grok-4": 256000, # grok-4, grok-4-0709
|
||||
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
|
||||
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
|
||||
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
|
||||
# Kimi
|
||||
"kimi": 262144,
|
||||
# Arcee
|
||||
|
||||
@@ -40,7 +40,7 @@ _CONTEXT_THREAT_PATTERNS = [
|
||||
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
|
||||
(r'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"),
|
||||
(r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),
|
||||
(r'<\s*div\s+style\s*=\s*["\'].*display\s*:\s*none', "hidden_div"),
|
||||
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),
|
||||
(r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),
|
||||
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
|
||||
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
|
||||
|
||||
@@ -97,8 +97,12 @@ def parse_rate_limit_headers(
|
||||
|
||||
Returns None if no rate limit headers are present.
|
||||
"""
|
||||
# Normalize to lowercase so lookups work regardless of how the server
|
||||
# capitalises headers (HTTP header names are case-insensitive per RFC 7230).
|
||||
lowered = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
# Quick check: at least one rate limit header must exist
|
||||
has_any = any(k.lower().startswith("x-ratelimit-") for k in headers)
|
||||
has_any = any(k.startswith("x-ratelimit-") for k in lowered)
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
@@ -109,9 +113,9 @@ def parse_rate_limit_headers(
|
||||
# resource="tokens", suffix="-1h" -> per-hour
|
||||
tag = f"{resource}{suffix}"
|
||||
return RateLimitBucket(
|
||||
limit=_safe_int(headers.get(f"x-ratelimit-limit-{tag}")),
|
||||
remaining=_safe_int(headers.get(f"x-ratelimit-remaining-{tag}")),
|
||||
reset_seconds=_safe_float(headers.get(f"x-ratelimit-reset-{tag}")),
|
||||
limit=_safe_int(lowered.get(f"x-ratelimit-limit-{tag}")),
|
||||
remaining=_safe_int(lowered.get(f"x-ratelimit-remaining-{tag}")),
|
||||
reset_seconds=_safe_float(lowered.get(f"x-ratelimit-reset-{tag}")),
|
||||
captured_at=now,
|
||||
)
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
|
||||
"api_mode": runtime.get("api_mode"),
|
||||
"command": runtime.get("command"),
|
||||
"args": list(runtime.get("args") or []),
|
||||
"credential_pool": runtime.get("credential_pool"),
|
||||
},
|
||||
"label": f"smart route → {route.get('model')} ({runtime.get('provider')})",
|
||||
"signature": (
|
||||
|
||||
@@ -684,7 +684,11 @@ platform_toolsets:
|
||||
stt:
|
||||
enabled: true
|
||||
# provider: "local" # auto-detected if omitted
|
||||
model: "whisper-1" # whisper-1 (cheapest) | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||
local:
|
||||
model: "base" # tiny | base | small | medium | large-v3 | turbo
|
||||
# language: "" # auto-detect; set to "en", "es", "fr", etc. to force
|
||||
openai:
|
||||
model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||
# mistral:
|
||||
# model: "voxtral-mini-latest" # voxtral-mini-latest | voxtral-mini-2602
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Termux / Android dependency constraints for Hermes Agent.
|
||||
#
|
||||
# Usage:
|
||||
# python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
#
|
||||
# These pins keep the tested Android install path stable when upstream packages
|
||||
# move faster than Termux-compatible wheels / sdists.
|
||||
|
||||
ipython<10
|
||||
jedi>=0.18.1,<0.20
|
||||
parso>=0.8.4,<0.9
|
||||
stack-data>=0.6,<0.7
|
||||
pexpect>4.3,<5
|
||||
matplotlib-inline>=0.1.7,<0.2
|
||||
asttokens>=2.1,<3
|
||||
+60
-3
@@ -346,7 +346,42 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
return None
|
||||
|
||||
|
||||
_SCRIPT_TIMEOUT = 120 # seconds
|
||||
_DEFAULT_SCRIPT_TIMEOUT = 120 # seconds
|
||||
# Backward-compatible module override used by tests and emergency monkeypatches.
|
||||
_SCRIPT_TIMEOUT = _DEFAULT_SCRIPT_TIMEOUT
|
||||
|
||||
|
||||
def _get_script_timeout() -> int:
|
||||
"""Resolve cron pre-run script timeout from module/env/config with a safe default."""
|
||||
if _SCRIPT_TIMEOUT != _DEFAULT_SCRIPT_TIMEOUT:
|
||||
try:
|
||||
timeout = int(float(_SCRIPT_TIMEOUT))
|
||||
if timeout > 0:
|
||||
return timeout
|
||||
except Exception:
|
||||
logger.warning("Invalid patched _SCRIPT_TIMEOUT=%r; using env/config/default", _SCRIPT_TIMEOUT)
|
||||
|
||||
env_value = os.getenv("HERMES_CRON_SCRIPT_TIMEOUT", "").strip()
|
||||
if env_value:
|
||||
try:
|
||||
timeout = int(float(env_value))
|
||||
if timeout > 0:
|
||||
return timeout
|
||||
except Exception:
|
||||
logger.warning("Invalid HERMES_CRON_SCRIPT_TIMEOUT=%r; using config/default", env_value)
|
||||
|
||||
try:
|
||||
cfg = load_config() or {}
|
||||
cron_cfg = cfg.get("cron", {}) if isinstance(cfg, dict) else {}
|
||||
configured = cron_cfg.get("script_timeout_seconds")
|
||||
if configured is not None:
|
||||
timeout = int(float(configured))
|
||||
if timeout > 0:
|
||||
return timeout
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to load cron script timeout from config: %s", exc)
|
||||
|
||||
return _DEFAULT_SCRIPT_TIMEOUT
|
||||
|
||||
|
||||
def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
@@ -393,12 +428,14 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
if not path.is_file():
|
||||
return False, f"Script path is not a file: {path}"
|
||||
|
||||
script_timeout = _get_script_timeout()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_SCRIPT_TIMEOUT,
|
||||
timeout=script_timeout,
|
||||
cwd=str(path.parent),
|
||||
)
|
||||
stdout = (result.stdout or "").strip()
|
||||
@@ -422,7 +459,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
return True, stdout
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"Script timed out after {_SCRIPT_TIMEOUT}s: {path}"
|
||||
return False, f"Script timed out after {script_timeout}s: {path}"
|
||||
except Exception as exc:
|
||||
return False, f"Script execution failed: {exc}"
|
||||
|
||||
@@ -646,6 +683,24 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
},
|
||||
)
|
||||
|
||||
fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None
|
||||
credential_pool = None
|
||||
runtime_provider = str(turn_route["runtime"].get("provider") or "").strip().lower()
|
||||
if runtime_provider:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool(runtime_provider)
|
||||
if pool.has_credentials():
|
||||
credential_pool = pool
|
||||
logger.info(
|
||||
"Job '%s': loaded credential pool for provider %s with %d entries",
|
||||
job_id,
|
||||
runtime_provider,
|
||||
len(pool.entries()),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e)
|
||||
|
||||
agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
api_key=turn_route["runtime"].get("api_key"),
|
||||
@@ -657,6 +712,8 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
max_iterations=max_iterations,
|
||||
reasoning_config=reasoning_config,
|
||||
prefill_messages=prefill_messages,
|
||||
fallback_model=fallback_model,
|
||||
credential_pool=credential_pool,
|
||||
providers_allowed=pr.get("only"),
|
||||
providers_ignored=pr.get("ignore"),
|
||||
providers_order=pr.get("order"),
|
||||
|
||||
@@ -536,6 +536,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
|
||||
if "mention_patterns" in platform_cfg:
|
||||
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
||||
if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg:
|
||||
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]
|
||||
if not bridged:
|
||||
continue
|
||||
plat_data = platforms_data.setdefault(plat.value, {})
|
||||
@@ -581,6 +583,12 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(ic, list):
|
||||
ic = ",".join(str(v) for v in ic)
|
||||
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = discord_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
|
||||
# no_thread_channels: channels where bot responds directly without creating thread
|
||||
ntc = discord_cfg.get("no_thread_channels")
|
||||
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
|
||||
@@ -901,6 +909,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
pass
|
||||
if api_server_host:
|
||||
config.platforms[Platform.API_SERVER].extra["host"] = api_server_host
|
||||
api_server_model_name = os.getenv("API_SERVER_MODEL_NAME", "")
|
||||
if api_server_model_name:
|
||||
config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name
|
||||
|
||||
# Webhook platform
|
||||
webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes")
|
||||
|
||||
@@ -20,10 +20,12 @@ Requires:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
@@ -282,6 +284,24 @@ def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
|
||||
return sha256(repr(subset).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _derive_chat_session_id(
|
||||
system_prompt: Optional[str],
|
||||
first_user_message: str,
|
||||
) -> str:
|
||||
"""Derive a stable session ID from the conversation's first user message.
|
||||
|
||||
OpenAI-compatible frontends (Open WebUI, LibreChat, etc.) send the full
|
||||
conversation history with every request. The system prompt and first user
|
||||
message are constant across all turns of the same conversation, so hashing
|
||||
them produces a deterministic session ID that lets the API server reuse
|
||||
the same Hermes session (and therefore the same Docker container sandbox
|
||||
directory) across turns.
|
||||
"""
|
||||
seed = f"{system_prompt or ''}\n{first_user_message}"
|
||||
digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
||||
return f"api-{digest}"
|
||||
|
||||
|
||||
class APIServerAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
OpenAI-compatible HTTP API server adapter.
|
||||
@@ -299,6 +319,9 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
|
||||
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
|
||||
)
|
||||
self._model_name: str = self._resolve_model_name(
|
||||
extra.get("model_name", os.getenv("API_SERVER_MODEL_NAME", "")),
|
||||
)
|
||||
self._app: Optional["web.Application"] = None
|
||||
self._runner: Optional["web.AppRunner"] = None
|
||||
self._site: Optional["web.TCPSite"] = None
|
||||
@@ -324,6 +347,26 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
return tuple(str(item).strip() for item in items if str(item).strip())
|
||||
|
||||
@staticmethod
|
||||
def _resolve_model_name(explicit: str) -> str:
|
||||
"""Derive the advertised model name for /v1/models.
|
||||
|
||||
Priority:
|
||||
1. Explicit override (config extra or API_SERVER_MODEL_NAME env var)
|
||||
2. Active profile name (so each profile advertises a distinct model)
|
||||
3. Fallback: "hermes-agent"
|
||||
"""
|
||||
if explicit and explicit.strip():
|
||||
return explicit.strip()
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name()
|
||||
if profile and profile not in ("default", "custom"):
|
||||
return profile
|
||||
except Exception:
|
||||
pass
|
||||
return "hermes-agent"
|
||||
|
||||
def _cors_headers_for_origin(self, origin: str) -> Optional[Dict[str, str]]:
|
||||
"""Return CORS headers for an allowed browser origin."""
|
||||
if not origin or not self._cors_origins:
|
||||
@@ -468,12 +511,12 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"id": self._model_name,
|
||||
"object": "model",
|
||||
"created": int(time.time()),
|
||||
"owned_by": "hermes",
|
||||
"permission": [],
|
||||
"root": "hermes-agent",
|
||||
"root": self._model_name,
|
||||
"parent": None,
|
||||
}
|
||||
],
|
||||
@@ -531,8 +574,32 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
# Allow caller to continue an existing session by passing X-Hermes-Session-Id.
|
||||
# When provided, history is loaded from state.db instead of from the request body.
|
||||
#
|
||||
# Security: session continuation exposes conversation history, so it is
|
||||
# only allowed when the API key is configured and the request is
|
||||
# authenticated. Without this gate, any unauthenticated client could
|
||||
# read arbitrary session history by guessing/enumerating session IDs.
|
||||
provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip()
|
||||
if provided_session_id:
|
||||
if not self._api_key:
|
||||
logger.warning(
|
||||
"Session continuation via X-Hermes-Session-Id rejected: "
|
||||
"no API key configured. Set API_SERVER_KEY to enable "
|
||||
"session continuity."
|
||||
)
|
||||
return web.json_response(
|
||||
_openai_error(
|
||||
"Session continuation requires API key authentication. "
|
||||
"Configure API_SERVER_KEY to enable this feature."
|
||||
),
|
||||
status=403,
|
||||
)
|
||||
# Sanitize: reject control characters that could enable header injection.
|
||||
if re.search(r'[\r\n\x00]', provided_session_id):
|
||||
return web.json_response(
|
||||
{"error": {"message": "Invalid session ID", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
session_id = provided_session_id
|
||||
try:
|
||||
db = self._ensure_session_db()
|
||||
@@ -542,11 +609,20 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
logger.warning("Failed to load session history for %s: %s", session_id, e)
|
||||
history = []
|
||||
else:
|
||||
session_id = str(uuid.uuid4())
|
||||
# Derive a stable session ID from the conversation fingerprint so
|
||||
# that consecutive messages from the same Open WebUI (or similar)
|
||||
# conversation map to the same Hermes session. The first user
|
||||
# message + system prompt are constant across all turns.
|
||||
first_user = ""
|
||||
for cm in conversation_messages:
|
||||
if cm.get("role") == "user":
|
||||
first_user = cm.get("content", "")
|
||||
break
|
||||
session_id = _derive_chat_session_id(system_prompt, first_user)
|
||||
# history already set from request body above
|
||||
|
||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
||||
model_name = body.get("model", "hermes-agent")
|
||||
model_name = body.get("model", self._model_name)
|
||||
created = int(time.time())
|
||||
|
||||
if stream:
|
||||
@@ -923,7 +999,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"created_at": created_at,
|
||||
"model": body.get("model", "hermes-agent"),
|
||||
"model": body.get("model", self._model_name),
|
||||
"output": output_items,
|
||||
"usage": {
|
||||
"input_tokens": usage.get("input_tokens", 0),
|
||||
@@ -1318,6 +1394,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id="default",
|
||||
)
|
||||
usage = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
@@ -1484,6 +1561,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
r = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id="default",
|
||||
)
|
||||
u = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
@@ -1652,9 +1730,17 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
await self._site.start()
|
||||
|
||||
self._mark_connected()
|
||||
if not self._api_key:
|
||||
logger.warning(
|
||||
"[%s] ⚠️ No API key configured (API_SERVER_KEY / platforms.api_server.key). "
|
||||
"All requests will be accepted without authentication. "
|
||||
"Set an API key for production deployments to prevent "
|
||||
"unauthorized access to sessions, responses, and cron jobs.",
|
||||
self.name,
|
||||
)
|
||||
logger.info(
|
||||
"[%s] API server listening on http://%s:%d",
|
||||
self.name, self._host, self._port,
|
||||
"[%s] API server listening on http://%s:%d (model: %s)",
|
||||
self.name, self._host, self._port, self._model_name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
+88
-15
@@ -160,7 +160,7 @@ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
)
|
||||
|
||||
|
||||
def _safe_url_for_log(url: str, max_len: int = 80) -> str:
|
||||
def safe_url_for_log(url: str, max_len: int = 80) -> str:
|
||||
"""Return a URL string safe for logs (no query/fragment/userinfo)."""
|
||||
if max_len <= 0:
|
||||
return ""
|
||||
@@ -197,6 +197,23 @@ def _safe_url_for_log(url: str, max_len: int = 80) -> str:
|
||||
return f"{safe[:max_len - 3]}..."
|
||||
|
||||
|
||||
async def _ssrf_redirect_guard(response):
|
||||
"""Re-validate each redirect target to prevent redirect-based SSRF.
|
||||
|
||||
Without this, an attacker can host a public URL that 302-redirects to
|
||||
http://169.254.169.254/ and bypass the pre-flight is_safe_url() check.
|
||||
|
||||
Must be async because httpx.AsyncClient awaits response event hooks.
|
||||
"""
|
||||
if response.is_redirect and response.next_request:
|
||||
redirect_url = str(response.next_request.url)
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(redirect_url):
|
||||
raise ValueError(
|
||||
f"Blocked redirect to private/internal address: {safe_url_for_log(redirect_url)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image cache utilities
|
||||
#
|
||||
@@ -216,6 +233,23 @@ def get_image_cache_dir() -> Path:
|
||||
return IMAGE_CACHE_DIR
|
||||
|
||||
|
||||
def _looks_like_image(data: bytes) -> bool:
|
||||
"""Return True if *data* starts with a known image magic-byte sequence."""
|
||||
if len(data) < 4:
|
||||
return False
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return True
|
||||
if data[:3] == b"\xff\xd8\xff":
|
||||
return True
|
||||
if data[:6] in (b"GIF87a", b"GIF89a"):
|
||||
return True
|
||||
if data[:2] == b"BM":
|
||||
return True
|
||||
if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
|
||||
"""
|
||||
Save raw image bytes to the cache and return the absolute file path.
|
||||
@@ -226,7 +260,17 @@ def cache_image_from_bytes(data: bytes, ext: str = ".jpg") -> str:
|
||||
|
||||
Returns:
|
||||
Absolute path to the cached image file as a string.
|
||||
|
||||
Raises:
|
||||
ValueError: If *data* does not look like a valid image (e.g. an HTML
|
||||
error page returned by the upstream server).
|
||||
"""
|
||||
if not _looks_like_image(data):
|
||||
snippet = data[:80].decode("utf-8", errors="replace")
|
||||
raise ValueError(
|
||||
f"Refusing to cache non-image data as {ext} "
|
||||
f"(starts with: {snippet!r})"
|
||||
)
|
||||
cache_dir = get_image_cache_dir()
|
||||
filename = f"img_{uuid.uuid4().hex[:12]}{ext}"
|
||||
filepath = cache_dir / filename
|
||||
@@ -254,7 +298,7 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->
|
||||
"""
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(url):
|
||||
raise ValueError(f"Blocked unsafe URL (SSRF protection): {_safe_url_for_log(url)}")
|
||||
raise ValueError(f"Blocked unsafe URL (SSRF protection): {safe_url_for_log(url)}")
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
@@ -262,7 +306,11 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->
|
||||
_log = _logging.getLogger(__name__)
|
||||
|
||||
last_exc = None
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
event_hooks={"response": [_ssrf_redirect_guard]},
|
||||
) as client:
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = await client.get(
|
||||
@@ -284,7 +332,7 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->
|
||||
"Media cache retry %d/%d for %s (%.1fs): %s",
|
||||
attempt + 1,
|
||||
retries,
|
||||
_safe_url_for_log(url),
|
||||
safe_url_for_log(url),
|
||||
wait,
|
||||
exc,
|
||||
)
|
||||
@@ -369,7 +417,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
|
||||
"""
|
||||
from tools.url_safety import is_safe_url
|
||||
if not is_safe_url(url):
|
||||
raise ValueError(f"Blocked unsafe URL (SSRF protection): {_safe_url_for_log(url)}")
|
||||
raise ValueError(f"Blocked unsafe URL (SSRF protection): {safe_url_for_log(url)}")
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
@@ -377,7 +425,11 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
|
||||
_log = _logging.getLogger(__name__)
|
||||
|
||||
last_exc = None
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
event_hooks={"response": [_ssrf_redirect_guard]},
|
||||
) as client:
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = await client.get(
|
||||
@@ -399,7 +451,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
|
||||
"Audio cache retry %d/%d for %s (%.1fs): %s",
|
||||
attempt + 1,
|
||||
retries,
|
||||
_safe_url_for_log(url),
|
||||
safe_url_for_log(url),
|
||||
wait,
|
||||
exc,
|
||||
)
|
||||
@@ -502,6 +554,14 @@ class MessageType(Enum):
|
||||
COMMAND = "command" # /command style
|
||||
|
||||
|
||||
class ProcessingOutcome(Enum):
|
||||
"""Result classification for message-processing lifecycle hooks."""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageEvent:
|
||||
"""
|
||||
@@ -529,8 +589,9 @@ class MessageEvent:
|
||||
reply_to_message_id: Optional[str] = None
|
||||
reply_to_text: Optional[str] = None # Text of the replied-to message (for context injection)
|
||||
|
||||
# Auto-loaded skill for topic/channel bindings (e.g., Telegram DM Topics)
|
||||
auto_skill: Optional[str] = None
|
||||
# Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics,
|
||||
# Discord channel_skill_bindings). A single name or ordered list.
|
||||
auto_skill: Optional[str | list[str]] = None
|
||||
|
||||
# Internal flag — set for synthetic events (e.g. background process
|
||||
# completion notifications) that must bypass user authorization checks.
|
||||
@@ -625,6 +686,7 @@ class BasePlatformAdapter(ABC):
|
||||
# Gateway shutdown cancels these so an old gateway instance doesn't keep
|
||||
# working on a task after --replace or manual restarts.
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
self._expected_cancelled_tasks: set[asyncio.Task] = set()
|
||||
# Chats where auto-TTS on voice input is disabled (set by /voice off)
|
||||
self._auto_tts_disabled_chats: set = set()
|
||||
# Chats where typing indicator is paused (e.g. during approval waits).
|
||||
@@ -1133,7 +1195,7 @@ class BasePlatformAdapter(ABC):
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Hook called when background processing begins."""
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||||
"""Hook called when background processing completes."""
|
||||
|
||||
async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
|
||||
@@ -1294,7 +1356,7 @@ class BasePlatformAdapter(ABC):
|
||||
# session lifecycle and its cleanup races with the running task
|
||||
# (see PR #4926).
|
||||
cmd = event.get_command()
|
||||
if cmd in ("approve", "deny", "status", "stop", "new", "reset"):
|
||||
if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background"):
|
||||
logger.debug(
|
||||
"[%s] Command '/%s' bypassing active-session guard for %s",
|
||||
self.name, cmd, session_key,
|
||||
@@ -1352,6 +1414,7 @@ class BasePlatformAdapter(ABC):
|
||||
return
|
||||
if hasattr(task, "add_done_callback"):
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
task.add_done_callback(self._expected_cancelled_tasks.discard)
|
||||
|
||||
@staticmethod
|
||||
def _get_human_delay() -> float:
|
||||
@@ -1488,7 +1551,7 @@ class BasePlatformAdapter(ABC):
|
||||
logger.info(
|
||||
"[%s] Sending image: %s (alt=%s)",
|
||||
self.name,
|
||||
_safe_url_for_log(image_url),
|
||||
safe_url_for_log(image_url),
|
||||
alt_text[:30] if alt_text else "",
|
||||
)
|
||||
# Route animated GIFs through send_animation for proper playback
|
||||
@@ -1580,7 +1643,11 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Determine overall success for the processing hook
|
||||
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
|
||||
await self._run_processing_hook("on_processing_complete", event, processing_ok)
|
||||
await self._run_processing_hook(
|
||||
"on_processing_complete",
|
||||
event,
|
||||
ProcessingOutcome.SUCCESS if processing_ok else ProcessingOutcome.FAILURE,
|
||||
)
|
||||
|
||||
# Check if there's a pending message that was queued during our processing
|
||||
if session_key in self._pending_messages:
|
||||
@@ -1599,10 +1666,14 @@ class BasePlatformAdapter(ABC):
|
||||
return # Already cleaned up
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._run_processing_hook("on_processing_complete", event, False)
|
||||
current_task = asyncio.current_task()
|
||||
outcome = ProcessingOutcome.CANCELLED
|
||||
if current_task is None or current_task not in self._expected_cancelled_tasks:
|
||||
outcome = ProcessingOutcome.FAILURE
|
||||
await self._run_processing_hook("on_processing_complete", event, outcome)
|
||||
raise
|
||||
except Exception as e:
|
||||
await self._run_processing_hook("on_processing_complete", event, False)
|
||||
await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.FAILURE)
|
||||
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
|
||||
# Send the error to the user so they aren't left with radio silence
|
||||
try:
|
||||
@@ -1646,10 +1717,12 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
tasks = [task for task in self._background_tasks if not task.done()]
|
||||
for task in tasks:
|
||||
self._expected_cancelled_tasks.add(task)
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._background_tasks.clear()
|
||||
self._expected_cancelled_tasks.clear()
|
||||
self._pending_messages.clear()
|
||||
self._active_sessions.clear()
|
||||
|
||||
|
||||
@@ -207,9 +207,17 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
self.webhook_port,
|
||||
self.webhook_path,
|
||||
)
|
||||
|
||||
# Register webhook with BlueBubbles server
|
||||
# This is required for the server to know where to send events
|
||||
await self._register_webhook()
|
||||
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
# Unregister webhook before cleaning up
|
||||
await self._unregister_webhook()
|
||||
|
||||
if self.client:
|
||||
await self.client.aclose()
|
||||
self.client = None
|
||||
@@ -218,6 +226,105 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
self._runner = None
|
||||
self._mark_disconnected()
|
||||
|
||||
@property
|
||||
def _webhook_url(self) -> str:
|
||||
"""Compute the external webhook URL for BlueBubbles registration."""
|
||||
host = self.webhook_host
|
||||
if host in ("0.0.0.0", "127.0.0.1", "localhost", "::"):
|
||||
host = "localhost"
|
||||
return f"http://{host}:{self.webhook_port}{self.webhook_path}"
|
||||
|
||||
async def _find_registered_webhooks(self, url: str) -> list:
|
||||
"""Return list of BB webhook entries matching *url*."""
|
||||
try:
|
||||
res = await self._api_get("/api/v1/webhook")
|
||||
data = res.get("data")
|
||||
if isinstance(data, list):
|
||||
return [wh for wh in data if wh.get("url") == url]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def _register_webhook(self) -> bool:
|
||||
"""Register this webhook URL with the BlueBubbles server.
|
||||
|
||||
BlueBubbles requires webhooks to be registered via API before
|
||||
it will send events. Checks for an existing registration first
|
||||
to avoid duplicates (e.g. after a crash without clean shutdown).
|
||||
"""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
webhook_url = self._webhook_url
|
||||
|
||||
# Crash resilience — reuse an existing registration if present
|
||||
existing = await self._find_registered_webhooks(webhook_url)
|
||||
if existing:
|
||||
logger.info(
|
||||
"[bluebubbles] webhook already registered: %s", webhook_url
|
||||
)
|
||||
return True
|
||||
|
||||
payload = {
|
||||
"url": webhook_url,
|
||||
"events": ["new-message", "updated-message", "message"],
|
||||
}
|
||||
|
||||
try:
|
||||
res = await self._api_post("/api/v1/webhook", payload)
|
||||
status = res.get("status", 0)
|
||||
if 200 <= status < 300:
|
||||
logger.info(
|
||||
"[bluebubbles] webhook registered with server: %s",
|
||||
webhook_url,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"[bluebubbles] webhook registration returned status %s: %s",
|
||||
status,
|
||||
res.get("message"),
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[bluebubbles] failed to register webhook with server: %s",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _unregister_webhook(self) -> bool:
|
||||
"""Unregister this webhook URL from the BlueBubbles server.
|
||||
|
||||
Removes *all* matching registrations to clean up any duplicates
|
||||
left by prior crashes.
|
||||
"""
|
||||
if not self.client:
|
||||
return False
|
||||
|
||||
webhook_url = self._webhook_url
|
||||
removed = False
|
||||
|
||||
try:
|
||||
for wh in await self._find_registered_webhooks(webhook_url):
|
||||
wh_id = wh.get("id")
|
||||
if wh_id:
|
||||
res = await self.client.delete(
|
||||
self._api_url(f"/api/v1/webhook/{wh_id}")
|
||||
)
|
||||
res.raise_for_status()
|
||||
removed = True
|
||||
if removed:
|
||||
logger.info(
|
||||
"[bluebubbles] webhook unregistered: %s", webhook_url
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"[bluebubbles] failed to unregister webhook (non-critical): %s",
|
||||
exc,
|
||||
)
|
||||
return removed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Chat GUID resolution
|
||||
# ------------------------------------------------------------------
|
||||
@@ -826,3 +933,4 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
asyncio.create_task(self.mark_read(session_chat_id))
|
||||
|
||||
return web.Response(text="ok")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Configuration in config.yaml:
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -54,6 +55,8 @@ MAX_MESSAGE_LENGTH = 20000
|
||||
DEDUP_WINDOW_SECONDS = 300
|
||||
DEDUP_MAX_SIZE = 1000
|
||||
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
|
||||
_SESSION_WEBHOOKS_MAX = 500
|
||||
_DINGTALK_WEBHOOK_RE = re.compile(r'^https://api\.dingtalk\.com/')
|
||||
|
||||
|
||||
def check_dingtalk_requirements() -> bool:
|
||||
@@ -195,9 +198,15 @@ class DingTalkAdapter(BasePlatformAdapter):
|
||||
chat_id = conversation_id or sender_id
|
||||
chat_type = "group" if is_group else "dm"
|
||||
|
||||
# Store session webhook for reply routing
|
||||
# Store session webhook for reply routing (validate origin to prevent SSRF)
|
||||
session_webhook = getattr(message, "session_webhook", None) or ""
|
||||
if session_webhook and chat_id:
|
||||
if session_webhook and chat_id and _DINGTALK_WEBHOOK_RE.match(session_webhook):
|
||||
if len(self._session_webhooks) >= _SESSION_WEBHOOKS_MAX:
|
||||
# Evict oldest entry to cap memory growth
|
||||
try:
|
||||
self._session_webhooks.pop(next(iter(self._session_webhooks)))
|
||||
except StopIteration:
|
||||
pass
|
||||
self._session_webhooks[chat_id] = session_webhook
|
||||
|
||||
source = self.build_source(
|
||||
|
||||
+157
-16
@@ -49,6 +49,7 @@ from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
cache_image_from_url,
|
||||
cache_audio_from_url,
|
||||
@@ -422,6 +423,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
# Discord message limits
|
||||
MAX_MESSAGE_LENGTH = 2000
|
||||
_SPLIT_THRESHOLD = 1900 # near the 2000-char split point
|
||||
|
||||
# Auto-disconnect from voice channel after this many seconds of inactivity
|
||||
VOICE_TIMEOUT = 300
|
||||
@@ -433,6 +435,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._allowed_user_ids: set = set() # For button approval authorization
|
||||
# Voice channel state (per-guild)
|
||||
self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient
|
||||
# Text batching: merge rapid successive messages (Telegram-style)
|
||||
self._text_batch_delay_seconds = float(os.getenv("HERMES_DISCORD_TEXT_BATCH_DELAY_SECONDS", "0.6"))
|
||||
self._text_batch_split_delay_seconds = float(os.getenv("HERMES_DISCORD_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0"))
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id
|
||||
self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task
|
||||
# Phase 2: voice listening
|
||||
@@ -748,14 +755,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._add_reaction(message, "👀")
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||||
"""Swap the in-progress reaction for a final success/failure reaction."""
|
||||
if not self._reactions_enabled():
|
||||
return
|
||||
message = event.raw_message
|
||||
if hasattr(message, "add_reaction"):
|
||||
await self._remove_reaction(message, "👀")
|
||||
await self._add_reaction(message, "✅" if success else "❌")
|
||||
if outcome == ProcessingOutcome.SUCCESS:
|
||||
await self._add_reaction(message, "✅")
|
||||
elif outcome == ProcessingOutcome.FAILURE:
|
||||
await self._add_reaction(message, "❌")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
@@ -764,18 +774,34 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> SendResult:
|
||||
"""Send a message to a Discord channel."""
|
||||
"""Send a message to a Discord channel or thread.
|
||||
|
||||
When metadata contains a thread_id, the message is sent to that
|
||||
thread instead of the parent channel identified by chat_id.
|
||||
"""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
# Get the channel
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
# Determine target channel: thread_id in metadata takes precedence.
|
||||
thread_id = None
|
||||
if metadata and metadata.get("thread_id"):
|
||||
thread_id = metadata["thread_id"]
|
||||
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
if thread_id:
|
||||
# Fetch the thread directly — threads are addressed by their own ID.
|
||||
channel = self._client.get_channel(int(thread_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(thread_id))
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Thread {thread_id} not found")
|
||||
else:
|
||||
# Get the parent channel
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
if not channel:
|
||||
return SendResult(success=False, error=f"Channel {chat_id} not found")
|
||||
|
||||
# Format and split message if needed
|
||||
formatted = self.format_message(content)
|
||||
@@ -1238,9 +1264,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
await asyncio.to_thread(VoiceReceiver.pcm_to_wav, pcm_data, wav_path)
|
||||
|
||||
from tools.transcription_tools import transcribe_audio, get_stt_model_from_config
|
||||
stt_model = get_stt_model_from_config()
|
||||
result = await asyncio.to_thread(transcribe_audio, wav_path, model=stt_model)
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
result = await asyncio.to_thread(transcribe_audio, wav_path)
|
||||
|
||||
if not result.get("success"):
|
||||
return
|
||||
@@ -1867,14 +1892,42 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
chat_topic=chat_topic,
|
||||
)
|
||||
|
||||
_parent_id = str(getattr(getattr(interaction, "channel", None), "parent_id", "") or "")
|
||||
_skills = self._resolve_channel_skills(thread_id, _parent_id or None)
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=interaction,
|
||||
auto_skill=_skills,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
def _resolve_channel_skills(self, channel_id: str, parent_id: str | None = None) -> list[str] | None:
|
||||
"""Look up auto-skill bindings for a Discord channel/forum thread.
|
||||
|
||||
Config format (in platform extra):
|
||||
channel_skill_bindings:
|
||||
- id: "123456"
|
||||
skills: ["skill-a", "skill-b"]
|
||||
Also checks parent_id so forum threads inherit the forum's bindings.
|
||||
"""
|
||||
bindings = self.config.extra.get("channel_skill_bindings", [])
|
||||
if not bindings:
|
||||
return None
|
||||
ids_to_check = {channel_id}
|
||||
if parent_id:
|
||||
ids_to_check.add(parent_id)
|
||||
for entry in bindings:
|
||||
entry_id = str(entry.get("id", ""))
|
||||
if entry_id in ids_to_check:
|
||||
skills = entry.get("skills") or entry.get("skill")
|
||||
if isinstance(skills, str):
|
||||
return [skills]
|
||||
if isinstance(skills, list) and skills:
|
||||
return list(dict.fromkeys(skills)) # dedup, preserve order
|
||||
return None
|
||||
|
||||
def _thread_parent_channel(self, channel: Any) -> Any:
|
||||
"""Return the parent text channel when invoked from a thread."""
|
||||
return getattr(channel, "parent", None) or channel
|
||||
@@ -2228,6 +2281,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# discord.require_mention: Require @mention in server channels (default: true)
|
||||
# discord.free_response_channels: Channel IDs where bot responds without mention
|
||||
# discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned)
|
||||
# discord.allowed_channels: If set, bot ONLY responds in these channels (whitelist)
|
||||
# discord.no_thread_channels: Channel IDs where bot responds directly without creating thread
|
||||
# discord.auto_thread: Auto-create thread on @mention in channels (default: true)
|
||||
|
||||
@@ -2239,12 +2293,21 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
parent_channel_id = self._get_parent_channel_id(message.channel)
|
||||
|
||||
if not isinstance(message.channel, discord.DMChannel):
|
||||
# Check ignored channels first - never respond even when mentioned
|
||||
ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
|
||||
ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
|
||||
channel_ids = {str(message.channel.id)}
|
||||
if parent_channel_id:
|
||||
channel_ids.add(parent_channel_id)
|
||||
|
||||
# Check allowed channels - if set, only respond in these channels
|
||||
allowed_channels_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "")
|
||||
if allowed_channels_raw:
|
||||
allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()}
|
||||
if not (channel_ids & allowed_channels):
|
||||
logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_ids)
|
||||
return
|
||||
|
||||
# Check ignored channels - never respond even when mentioned
|
||||
ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
|
||||
ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
|
||||
if channel_ids & ignored_channels:
|
||||
logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
|
||||
return
|
||||
@@ -2449,6 +2512,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if not event_text or not event_text.strip():
|
||||
event_text = "(The user sent a message with no text content)"
|
||||
|
||||
_chan = message.channel
|
||||
_parent_id = str(getattr(_chan, "parent_id", "") or "")
|
||||
_chan_id = str(getattr(_chan, "id", ""))
|
||||
_skills = self._resolve_channel_skills(_chan_id, _parent_id or None)
|
||||
event = MessageEvent(
|
||||
text=event_text,
|
||||
message_type=msg_type,
|
||||
@@ -2459,6 +2526,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
media_types=media_types,
|
||||
reply_to_message_id=str(message.reference.message_id) if message.reference else None,
|
||||
timestamp=message.created_at,
|
||||
auto_skill=_skills,
|
||||
)
|
||||
|
||||
# Track thread participation so the bot won't require @mention for
|
||||
@@ -2466,7 +2534,80 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if thread_id:
|
||||
self._track_thread(thread_id)
|
||||
|
||||
await self.handle_message(event)
|
||||
# Only batch plain text messages — commands, media, etc. dispatch
|
||||
# immediately since they won't be split by the Discord client.
|
||||
if msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text message aggregation (handles Discord client-side splits)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When Discord splits a long user message at 2000 chars, the chunks
|
||||
arrive within a few hundred milliseconds. This merges them into
|
||||
a single event before dispatching.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for the quiet period then dispatch the aggregated text.
|
||||
|
||||
Uses a longer delay when the latest chunk is near Discord's 2000-char
|
||||
split point, since a continuation chunk is almost certain.
|
||||
"""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
logger.info(
|
||||
"[Discord] Flushing text batch %s (%d chars)",
|
||||
key, len(event.text or ""),
|
||||
)
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -195,7 +195,11 @@ def _extract_attachments(
|
||||
|
||||
ext = Path(filename).suffix.lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
cached_path = cache_image_from_bytes(payload, ext)
|
||||
try:
|
||||
cached_path = cache_image_from_bytes(payload, ext)
|
||||
except ValueError:
|
||||
logger.debug("Skipping non-image attachment %s (invalid magic bytes)", filename)
|
||||
continue
|
||||
attachments.append({
|
||||
"path": cached_path,
|
||||
"filename": filename,
|
||||
|
||||
+40
-10
@@ -264,6 +264,7 @@ class FeishuAdapterSettings:
|
||||
bot_name: str
|
||||
dedup_cache_size: int
|
||||
text_batch_delay_seconds: float
|
||||
text_batch_split_delay_seconds: float
|
||||
text_batch_max_messages: int
|
||||
text_batch_max_chars: int
|
||||
media_batch_delay_seconds: float
|
||||
@@ -972,7 +973,8 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
|
||||
return await original_connect(*args, **kwargs)
|
||||
|
||||
def _configure_with_overrides(conf: Any) -> Any:
|
||||
assert original_configure is not None
|
||||
if original_configure is None:
|
||||
raise RuntimeError("Feishu _configure_with_overrides called but original_configure is None")
|
||||
result = original_configure(conf)
|
||||
_apply_runtime_ws_overrides()
|
||||
return result
|
||||
@@ -1014,6 +1016,10 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
"""Feishu/Lark bot adapter."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
# Threshold for detecting Feishu client-side message splits.
|
||||
# When a chunk is near the ~4096-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
|
||||
# =========================================================================
|
||||
# Lifecycle — init / settings / connect / disconnect
|
||||
@@ -1105,6 +1111,9 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
text_batch_delay_seconds=float(
|
||||
os.getenv("HERMES_FEISHU_TEXT_BATCH_DELAY_SECONDS", str(_DEFAULT_TEXT_BATCH_DELAY_SECONDS))
|
||||
),
|
||||
text_batch_split_delay_seconds=float(
|
||||
os.getenv("HERMES_FEISHU_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0")
|
||||
),
|
||||
text_batch_max_messages=max(
|
||||
1,
|
||||
int(os.getenv("HERMES_FEISHU_TEXT_BATCH_MAX_MESSAGES", str(_DEFAULT_TEXT_BATCH_MAX_MESSAGES))),
|
||||
@@ -1152,6 +1161,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._bot_name = settings.bot_name
|
||||
self._dedup_cache_size = settings.dedup_cache_size
|
||||
self._text_batch_delay_seconds = settings.text_batch_delay_seconds
|
||||
self._text_batch_split_delay_seconds = settings.text_batch_split_delay_seconds
|
||||
self._text_batch_max_messages = settings.text_batch_max_messages
|
||||
self._text_batch_max_chars = settings.text_batch_max_chars
|
||||
self._media_batch_delay_seconds = settings.media_batch_delay_seconds
|
||||
@@ -1570,13 +1580,18 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=f"Image file not found: {image_path}")
|
||||
|
||||
try:
|
||||
with open(image_path, "rb") as image_file:
|
||||
body = self._build_image_upload_body(
|
||||
image_type=_FEISHU_IMAGE_UPLOAD_TYPE,
|
||||
image=image_file,
|
||||
)
|
||||
request = self._build_image_upload_request(body)
|
||||
upload_response = await asyncio.to_thread(self._client.im.v1.image.create, request)
|
||||
import io as _io
|
||||
with open(image_path, "rb") as f:
|
||||
image_bytes = f.read()
|
||||
# Wrap in BytesIO so lark SDK's MultipartEncoder can read .name and .tell()
|
||||
image_file = _io.BytesIO(image_bytes)
|
||||
image_file.name = os.path.basename(image_path)
|
||||
body = self._build_image_upload_body(
|
||||
image_type=_FEISHU_IMAGE_UPLOAD_TYPE,
|
||||
image=image_file,
|
||||
)
|
||||
request = self._build_image_upload_request(body)
|
||||
upload_response = await asyncio.to_thread(self._client.im.v1.image.create, request)
|
||||
image_key = self._extract_response_field(upload_response, "image_key")
|
||||
if not image_key:
|
||||
return self._response_error_result(
|
||||
@@ -2478,8 +2493,10 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
async def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Debounce rapid Feishu text bursts into a single MessageEvent."""
|
||||
key = self._text_batch_key(event)
|
||||
chunk_len = len(event.text or "")
|
||||
existing = self._pending_text_batches.get(key)
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
self._pending_text_batch_counts[key] = 1
|
||||
self._schedule_text_batch_flush(key)
|
||||
@@ -2504,6 +2521,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
return
|
||||
|
||||
existing.text = next_text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
existing.timestamp = event.timestamp
|
||||
if event.message_id:
|
||||
existing.message_id = event.message_id
|
||||
@@ -2530,10 +2548,22 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
task_map[key] = asyncio.create_task(flush_fn(key))
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Flush a pending text batch after the quiet period."""
|
||||
"""Flush a pending text batch after the quiet period.
|
||||
|
||||
Uses a longer delay when the latest chunk is near Feishu's ~4096-char
|
||||
split point, since a continuation chunk is almost certain.
|
||||
"""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
await asyncio.sleep(self._text_batch_delay_seconds)
|
||||
# Adaptive delay: if the latest chunk is near the split threshold,
|
||||
# a continuation is almost certain — wait longer.
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
await self._flush_text_batch_now(key)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
|
||||
+114
-13
@@ -40,6 +40,7 @@ from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
@@ -120,6 +121,11 @@ def check_matrix_requirements() -> bool:
|
||||
class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Gateway adapter for Matrix (any homeserver)."""
|
||||
|
||||
# Threshold for detecting Matrix client-side message splits.
|
||||
# When a chunk is near the ~4000-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
_SPLIT_THRESHOLD = 3900
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.MATRIX)
|
||||
|
||||
@@ -171,6 +177,16 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._reactions_enabled: bool = os.getenv(
|
||||
"MATRIX_REACTIONS", "true"
|
||||
).lower() not in ("false", "0", "no")
|
||||
# Tracks the reaction event_id for in-progress (eyes) reactions.
|
||||
# Key: (room_id, message_event_id) → reaction_event_id (for the eyes reaction).
|
||||
self._pending_reactions: dict[tuple[str, str], str] = {}
|
||||
|
||||
# Text batching: merge rapid successive messages (Telegram-style).
|
||||
# Matrix clients split long messages around 4000 chars.
|
||||
self._text_batch_delay_seconds = float(os.getenv("HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS", "0.6"))
|
||||
self._text_batch_split_delay_seconds = float(os.getenv("HERMES_MATRIX_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0"))
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
def _is_duplicate_event(self, event_id) -> bool:
|
||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||
@@ -1088,7 +1104,81 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Acknowledge receipt so the room shows as read (fire-and-forget).
|
||||
self._background_read_receipt(room.room_id, event.event_id)
|
||||
|
||||
await self.handle_message(msg_event)
|
||||
# Only batch plain text messages — commands dispatch immediately.
|
||||
if msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:
|
||||
self._enqueue_text_event(msg_event)
|
||||
else:
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text message aggregation (handles Matrix client-side splits)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When a Matrix client splits a long message, the chunks arrive within
|
||||
a few hundred milliseconds. This merges them into a single event
|
||||
before dispatching.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
# Merge any media that might be attached
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
# Cancel any pending flush and restart the timer
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for the quiet period then dispatch the aggregated text.
|
||||
|
||||
Uses a longer delay when the latest chunk is near Matrix's ~4000-char
|
||||
split point, since a continuation chunk is almost certain.
|
||||
"""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
logger.info(
|
||||
"[Matrix] Flushing text batch %s (%d chars)",
|
||||
key, len(event.text or ""),
|
||||
)
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _on_room_message_media(self, room: Any, event: Any) -> None:
|
||||
"""Handle incoming media messages (images, audio, video, files)."""
|
||||
@@ -1350,12 +1440,14 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _send_reaction(
|
||||
self, room_id: str, event_id: str, emoji: str,
|
||||
) -> bool:
|
||||
"""Send an emoji reaction to a message in a room."""
|
||||
) -> Optional[str]:
|
||||
"""Send an emoji reaction to a message in a room.
|
||||
Returns the reaction event_id on success, None on failure.
|
||||
"""
|
||||
import nio
|
||||
|
||||
if not self._client:
|
||||
return False
|
||||
return None
|
||||
content = {
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
@@ -1370,12 +1462,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
if isinstance(resp, nio.RoomSendResponse):
|
||||
logger.debug("Matrix: sent reaction %s to %s", emoji, event_id)
|
||||
return True
|
||||
return resp.event_id
|
||||
logger.debug("Matrix: reaction send failed: %s", resp)
|
||||
return False
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: reaction send error: %s", exc)
|
||||
return False
|
||||
return None
|
||||
|
||||
async def _redact_reaction(
|
||||
self, room_id: str, reaction_event_id: str, reason: str = "",
|
||||
@@ -1390,10 +1482,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
msg_id = event.message_id
|
||||
room_id = event.source.chat_id
|
||||
if msg_id and room_id:
|
||||
await self._send_reaction(room_id, msg_id, "\U0001f440")
|
||||
reaction_event_id = await self._send_reaction(room_id, msg_id, "\U0001f440")
|
||||
if reaction_event_id:
|
||||
self._pending_reactions[(room_id, msg_id)] = reaction_event_id
|
||||
|
||||
async def on_processing_complete(
|
||||
self, event: MessageEvent, success: bool,
|
||||
self, event: MessageEvent, outcome: ProcessingOutcome,
|
||||
) -> None:
|
||||
"""Replace eyes with checkmark (success) or cross (failure)."""
|
||||
if not self._reactions_enabled:
|
||||
@@ -1402,11 +1496,18 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
room_id = event.source.chat_id
|
||||
if not msg_id or not room_id:
|
||||
return
|
||||
# Note: Matrix doesn't support removing a specific reaction easily
|
||||
# without tracking the reaction event_id. We send the new reaction;
|
||||
# the eyes stays (acceptable UX — both are visible).
|
||||
if outcome == ProcessingOutcome.CANCELLED:
|
||||
return
|
||||
# Remove the eyes reaction first, if we tracked its event_id.
|
||||
reaction_key = (room_id, msg_id)
|
||||
if reaction_key in self._pending_reactions:
|
||||
eyes_event_id = self._pending_reactions.pop(reaction_key)
|
||||
if not await self._redact_reaction(room_id, eyes_event_id):
|
||||
logger.debug("Matrix: failed to redact eyes reaction %s", eyes_event_id)
|
||||
await self._send_reaction(
|
||||
room_id, msg_id, "\u2705" if success else "\u274c",
|
||||
room_id,
|
||||
msg_id,
|
||||
"\u2705" if outcome == ProcessingOutcome.SUCCESS else "\u274c",
|
||||
)
|
||||
|
||||
async def _on_reaction(self, room: Any, event: Any) -> None:
|
||||
|
||||
@@ -39,6 +39,7 @@ from gateway.platforms.base import (
|
||||
MessageType,
|
||||
SendResult,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
safe_url_for_log,
|
||||
cache_document_from_bytes,
|
||||
)
|
||||
|
||||
@@ -656,8 +657,19 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
import httpx
|
||||
|
||||
async def _ssrf_redirect_guard(response):
|
||||
"""Re-check redirect targets so public URLs cannot bounce into private IPs."""
|
||||
if response.is_redirect and response.next_request:
|
||||
redirect_url = str(response.next_request.url)
|
||||
if not is_safe_url(redirect_url):
|
||||
raise ValueError("Blocked redirect to private/internal address")
|
||||
|
||||
# Download the image first
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
event_hooks={"response": [_ssrf_redirect_guard]},
|
||||
) as client:
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -674,7 +686,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"[Slack] Failed to upload image from URL %s, falling back to text: %s",
|
||||
image_url,
|
||||
safe_url_for_log(image_url),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -1596,6 +1608,18 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Slack may return an HTML sign-in/redirect page
|
||||
# instead of actual media bytes (e.g. expired token,
|
||||
# restricted file access). Detect this early so we
|
||||
# don't cache bogus data and confuse downstream tools.
|
||||
ct = response.headers.get("content-type", "")
|
||||
if "text/html" in ct:
|
||||
raise ValueError(
|
||||
"Slack returned HTML instead of media "
|
||||
f"(content-type: {ct}); "
|
||||
"check bot token scopes and file permissions"
|
||||
)
|
||||
|
||||
if audio:
|
||||
from gateway.platforms.base import cache_audio_from_bytes
|
||||
return cache_audio_from_bytes(response.content, ext)
|
||||
|
||||
@@ -60,6 +60,7 @@ from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
cache_image_from_bytes,
|
||||
cache_audio_from_bytes,
|
||||
@@ -121,6 +122,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
# Telegram message limits
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
# When a chunk is near this limit, a continuation is almost certain.
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
MEDIA_GROUP_WAIT_SECONDS = 0.8
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
@@ -140,6 +144,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Buffer rapid text messages so Telegram client-side splits of long
|
||||
# messages are aggregated into a single MessageEvent.
|
||||
self._text_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", "0.6"))
|
||||
self._text_batch_split_delay_seconds = float(os.getenv("HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0"))
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._token_lock_identity: Optional[str] = None
|
||||
@@ -513,6 +518,45 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
# Build the application
|
||||
builder = Application.builder().token(self.config.token)
|
||||
custom_base_url = self.config.extra.get("base_url")
|
||||
if custom_base_url:
|
||||
builder = builder.base_url(custom_base_url)
|
||||
builder = builder.base_file_url(
|
||||
self.config.extra.get("base_file_url", custom_base_url)
|
||||
)
|
||||
logger.info(
|
||||
"[%s] Using custom Telegram base_url: %s",
|
||||
self.name, custom_base_url,
|
||||
)
|
||||
|
||||
# PTB defaults (pool_timeout=1s) are too aggressive on flaky networks and
|
||||
# can trigger "Pool timeout: All connections in the connection pool are occupied"
|
||||
# during reconnect/bootstrap. Use safer defaults and allow env overrides.
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
request_kwargs = {
|
||||
"connection_pool_size": _env_int("HERMES_TELEGRAM_HTTP_POOL_SIZE", 512),
|
||||
"pool_timeout": _env_float("HERMES_TELEGRAM_HTTP_POOL_TIMEOUT", 8.0),
|
||||
"connect_timeout": _env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0),
|
||||
"read_timeout": _env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0),
|
||||
"write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
|
||||
}
|
||||
|
||||
proxy_configured = any(
|
||||
(os.getenv(k) or "").strip()
|
||||
for k in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
|
||||
)
|
||||
disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on"))
|
||||
fallback_ips = self._fallback_ips()
|
||||
if not fallback_ips:
|
||||
fallback_ips = await discover_fallback_ips()
|
||||
@@ -521,16 +565,32 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self.name,
|
||||
", ".join(fallback_ips),
|
||||
)
|
||||
if fallback_ips:
|
||||
|
||||
if fallback_ips and not proxy_configured and not disable_fallback:
|
||||
logger.info(
|
||||
"[%s] Telegram fallback IPs active: %s",
|
||||
self.name,
|
||||
", ".join(fallback_ips),
|
||||
)
|
||||
transport = TelegramFallbackTransport(fallback_ips)
|
||||
request = HTTPXRequest(httpx_kwargs={"transport": transport})
|
||||
get_updates_request = HTTPXRequest(httpx_kwargs={"transport": transport})
|
||||
builder = builder.request(request).get_updates_request(get_updates_request)
|
||||
# Keep request/update pools separate to reduce contention during
|
||||
# polling reconnect + bot API bootstrap/delete_webhook calls.
|
||||
request = HTTPXRequest(
|
||||
**request_kwargs,
|
||||
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
|
||||
)
|
||||
get_updates_request = HTTPXRequest(
|
||||
**request_kwargs,
|
||||
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
|
||||
)
|
||||
else:
|
||||
if proxy_configured:
|
||||
logger.info("[%s] Proxy configured; skipping Telegram fallback-IP transport", self.name)
|
||||
elif disable_fallback:
|
||||
logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
|
||||
request = HTTPXRequest(**request_kwargs)
|
||||
get_updates_request = HTTPXRequest(**request_kwargs)
|
||||
|
||||
builder = builder.request(request).get_updates_request(get_updates_request)
|
||||
self._app = builder.build()
|
||||
self._bot = self._app.bot
|
||||
|
||||
@@ -2160,12 +2220,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
# Append text from the follow-up chunk
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
# Merge any media that might be attached
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
@@ -2180,10 +2243,22 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for the quiet period then dispatch the aggregated text."""
|
||||
"""Wait for the quiet period then dispatch the aggregated text.
|
||||
|
||||
Uses a longer delay when the latest chunk is near Telegram's 4096-char
|
||||
split point, since a continuation chunk is almost certain.
|
||||
"""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
await asyncio.sleep(self._text_batch_delay_seconds)
|
||||
# Adaptive delay: if the latest chunk is near Telegram's 4096-char
|
||||
# split point, a continuation is almost certain — wait longer.
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
@@ -2713,7 +2788,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if chat_id and message_id:
|
||||
await self._set_reaction(chat_id, message_id, "\U0001f440")
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||||
"""Swap the in-progress reaction for a final success/failure reaction.
|
||||
|
||||
Unlike Discord (additive reactions), Telegram's set_message_reaction
|
||||
@@ -2723,5 +2798,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return
|
||||
chat_id = getattr(event.source, "chat_id", None)
|
||||
message_id = getattr(event, "message_id", None)
|
||||
if chat_id and message_id:
|
||||
await self._set_reaction(chat_id, message_id, "\u2705" if success else "\u274c")
|
||||
if chat_id and message_id and outcome != ProcessingOutcome.CANCELLED:
|
||||
await self._set_reaction(
|
||||
chat_id,
|
||||
message_id,
|
||||
"\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
|
||||
)
|
||||
|
||||
@@ -110,7 +110,8 @@ class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
||||
logger.warning("[Telegram] Fallback IP %s failed: %s", ip, exc)
|
||||
continue
|
||||
|
||||
assert last_error is not None
|
||||
if last_error is None:
|
||||
raise RuntimeError("All Telegram fallback IPs exhausted but no error was recorded")
|
||||
raise last_error
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -186,13 +186,22 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
if deliver_type == "github_comment":
|
||||
return await self._deliver_github_comment(content, delivery)
|
||||
|
||||
# Cross-platform delivery (telegram, discord, etc.)
|
||||
# Cross-platform delivery — any platform with a gateway adapter
|
||||
if self.gateway_runner and deliver_type in (
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"signal",
|
||||
"sms",
|
||||
"whatsapp",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"homeassistant",
|
||||
"email",
|
||||
"dingtalk",
|
||||
"feishu",
|
||||
"wecom",
|
||||
"bluebubbles",
|
||||
):
|
||||
return await self._deliver_cross_platform(
|
||||
deliver_type, content, delivery
|
||||
@@ -262,7 +271,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
", ".join(self._dynamic_routes.keys()) or "(none)",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[webhook] Failed to reload dynamic routes: %s", e)
|
||||
logger.error("[webhook] Failed to reload dynamic routes: %s", e)
|
||||
|
||||
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
||||
"""POST /webhooks/{route_name} — receive and process a webhook event."""
|
||||
|
||||
@@ -143,6 +143,9 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
"""WeCom AI Bot adapter backed by a persistent WebSocket connection."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
|
||||
# Threshold for detecting WeCom client-side message splits.
|
||||
# When a chunk is near the 4000-char limit, a continuation is almost certain.
|
||||
_SPLIT_THRESHOLD = 3900
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.WECOM)
|
||||
@@ -172,6 +175,13 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
self._seen_messages: Dict[str, float] = {}
|
||||
self._reply_req_ids: Dict[str, str] = {}
|
||||
|
||||
# Text batching: merge rapid successive messages (Telegram-style).
|
||||
# WeCom clients split long messages around 4000 chars.
|
||||
self._text_batch_delay_seconds = float(os.getenv("HERMES_WECOM_TEXT_BATCH_DELAY_SECONDS", "0.6"))
|
||||
self._text_batch_split_delay_seconds = float(os.getenv("HERMES_WECOM_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0"))
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
@@ -519,7 +529,82 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
await self.handle_message(event)
|
||||
# Only batch plain text messages — commands, media, etc. dispatch
|
||||
# immediately since they won't be split by the WeCom client.
|
||||
if message_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text message aggregation (handles WeCom client-side splits)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When WeCom splits a long user message at 4000 chars, the chunks
|
||||
arrive within a few hundred milliseconds. This merges them into
|
||||
a single event before dispatching.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
# Merge any media that might be attached
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
# Cancel any pending flush and restart the timer
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for the quiet period then dispatch the aggregated text.
|
||||
|
||||
Uses a longer delay when the latest chunk is near WeCom's 4000-char
|
||||
split point, since a continuation chunk is almost certain.
|
||||
"""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
logger.info(
|
||||
"[WeCom] Flushing text batch %s (%d chars)",
|
||||
key, len(event.text or ""),
|
||||
)
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]:
|
||||
@@ -611,7 +696,11 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
|
||||
if kind == "image":
|
||||
ext = self._detect_image_ext(raw)
|
||||
return cache_image_from_bytes(raw, ext), self._mime_for_ext(ext, fallback="image/jpeg")
|
||||
try:
|
||||
return cache_image_from_bytes(raw, ext), self._mime_for_ext(ext, fallback="image/jpeg")
|
||||
except ValueError as exc:
|
||||
logger.warning("[%s] Rejected non-image bytes: %s", self.name, exc)
|
||||
return None
|
||||
|
||||
filename = str(media.get("filename") or media.get("name") or "wecom_file")
|
||||
return cache_document_from_bytes(raw, filename), mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
@@ -637,7 +726,11 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
content_type = str(headers.get("content-type") or "").split(";", 1)[0].strip() or "application/octet-stream"
|
||||
if kind == "image":
|
||||
ext = self._guess_extension(url, content_type, fallback=self._detect_image_ext(raw))
|
||||
return cache_image_from_bytes(raw, ext), content_type or self._mime_for_ext(ext, fallback="image/jpeg")
|
||||
try:
|
||||
return cache_image_from_bytes(raw, ext), content_type or self._mime_for_ext(ext, fallback="image/jpeg")
|
||||
except ValueError as exc:
|
||||
logger.warning("[%s] Rejected non-image bytes from %s: %s", self.name, url, exc)
|
||||
return None
|
||||
|
||||
filename = self._guess_filename(url, headers.get("content-disposition"), content_type)
|
||||
return cache_document_from_bytes(raw, filename), content_type
|
||||
|
||||
+270
-52
@@ -481,6 +481,7 @@ class GatewayRunner:
|
||||
self._prefill_messages = self._load_prefill_messages()
|
||||
self._ephemeral_system_prompt = self._load_ephemeral_system_prompt()
|
||||
self._reasoning_config = self._load_reasoning_config()
|
||||
self._service_tier = self._load_service_tier()
|
||||
self._show_reasoning = self._load_show_reasoning()
|
||||
self._provider_routing = self._load_provider_routing()
|
||||
self._fallback_model = self._load_fallback_model()
|
||||
@@ -776,6 +777,7 @@ class GatewayRunner:
|
||||
|
||||
def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
|
||||
from agent.smart_model_routing import resolve_turn_route
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
primary = {
|
||||
"model": model,
|
||||
@@ -787,7 +789,19 @@ class GatewayRunner:
|
||||
"args": list(runtime_kwargs.get("args") or []),
|
||||
"credential_pool": runtime_kwargs.get("credential_pool"),
|
||||
}
|
||||
return resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
|
||||
route = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
|
||||
|
||||
service_tier = getattr(self, "_service_tier", None)
|
||||
if not service_tier:
|
||||
route["request_overrides"] = None
|
||||
return route
|
||||
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(route.get("model"))
|
||||
except Exception:
|
||||
overrides = None
|
||||
route["request_overrides"] = overrides
|
||||
return route
|
||||
|
||||
async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
|
||||
"""React to an adapter failure after startup.
|
||||
@@ -939,6 +953,33 @@ class GatewayRunner:
|
||||
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _load_service_tier() -> str | None:
|
||||
"""Load Priority Processing setting from config.yaml.
|
||||
|
||||
Reads agent.service_tier from config.yaml. Accepted values mirror the CLI:
|
||||
"fast"/"priority"/"on" => "priority", while "normal"/"off" disables it.
|
||||
Returns None when unset or unsupported.
|
||||
"""
|
||||
raw = ""
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
raw = str(cfg.get("agent", {}).get("service_tier", "") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
value = raw.lower()
|
||||
if not value or value in {"normal", "default", "standard", "off", "none"}:
|
||||
return None
|
||||
if value in {"fast", "priority", "on"}:
|
||||
return "priority"
|
||||
logger.warning("Unknown service_tier '%s', ignoring", raw)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _load_show_reasoning() -> bool:
|
||||
"""Load show_reasoning toggle from config.yaml display section."""
|
||||
@@ -1991,6 +2032,11 @@ class GatewayRunner:
|
||||
return await self._handle_approve_command(event)
|
||||
return await self._handle_deny_command(event)
|
||||
|
||||
# /background must bypass the running-agent guard — it starts a
|
||||
# parallel task and must never interrupt the active conversation.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "background":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
if event.message_type == MessageType.PHOTO:
|
||||
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
|
||||
adapter = self.adapters.get(source.platform)
|
||||
@@ -2072,6 +2118,9 @@ class GatewayRunner:
|
||||
if canonical == "reasoning":
|
||||
return await self._handle_reasoning_command(event)
|
||||
|
||||
if canonical == "fast":
|
||||
return await self._handle_fast_command(event)
|
||||
|
||||
if canonical == "verbose":
|
||||
return await self._handle_verbose_command(event)
|
||||
|
||||
@@ -2414,37 +2463,41 @@ class GatewayRunner:
|
||||
session_entry.was_auto_reset = False
|
||||
session_entry.auto_reset_reason = None
|
||||
|
||||
# Auto-load skill for DM topic bindings (e.g., Telegram Private Chat Topics)
|
||||
# Only inject on NEW sessions — for ongoing conversations the skill content
|
||||
# is already in the conversation history from the first message.
|
||||
if _is_new_session and getattr(event, "auto_skill", None):
|
||||
# Auto-load skill(s) for topic/channel bindings (Telegram DM Topics,
|
||||
# Discord channel_skill_bindings). Supports a single name or ordered list.
|
||||
# Only inject on NEW sessions — ongoing conversations already have the
|
||||
# skill content in their conversation history from the first message.
|
||||
_auto = getattr(event, "auto_skill", None)
|
||||
if _is_new_session and _auto:
|
||||
_skill_names = [_auto] if isinstance(_auto, str) else list(_auto)
|
||||
try:
|
||||
from agent.skill_commands import _load_skill_payload, _build_skill_message
|
||||
_skill_name = event.auto_skill
|
||||
_loaded = _load_skill_payload(_skill_name, task_id=_quick_key)
|
||||
if _loaded:
|
||||
_loaded_skill, _skill_dir, _display_name = _loaded
|
||||
_activation_note = (
|
||||
f'[SYSTEM: This conversation is in a topic with the "{_display_name}" skill '
|
||||
f"auto-loaded. Follow its instructions for the duration of this session.]"
|
||||
)
|
||||
_skill_msg = _build_skill_message(
|
||||
_loaded_skill, _skill_dir, _activation_note,
|
||||
user_instruction=event.text,
|
||||
)
|
||||
if _skill_msg:
|
||||
event.text = _skill_msg
|
||||
logger.info(
|
||||
"[Gateway] Auto-loaded skill '%s' for DM topic session %s",
|
||||
_skill_name, session_key,
|
||||
_combined_parts: list[str] = []
|
||||
_loaded_names: list[str] = []
|
||||
for _sname in _skill_names:
|
||||
_loaded = _load_skill_payload(_sname, task_id=_quick_key)
|
||||
if _loaded:
|
||||
_loaded_skill, _skill_dir, _display_name = _loaded
|
||||
_note = (
|
||||
f'[SYSTEM: The "{_display_name}" skill is auto-loaded. '
|
||||
f"Follow its instructions for this session.]"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[Gateway] DM topic skill '%s' not found in available skills",
|
||||
_skill_name,
|
||||
_part = _build_skill_message(_loaded_skill, _skill_dir, _note)
|
||||
if _part:
|
||||
_combined_parts.append(_part)
|
||||
_loaded_names.append(_sname)
|
||||
else:
|
||||
logger.warning("[Gateway] Auto-skill '%s' not found", _sname)
|
||||
if _combined_parts:
|
||||
# Append the user's original text after all skill payloads
|
||||
_combined_parts.append(event.text)
|
||||
event.text = "\n\n".join(_combined_parts)
|
||||
logger.info(
|
||||
"[Gateway] Auto-loaded skill(s) %s for session %s",
|
||||
_loaded_names, session_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[Gateway] Failed to auto-load topic skill '%s': %s", event.auto_skill, e)
|
||||
logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e)
|
||||
|
||||
# Load conversation history from transcript
|
||||
history = self.session_store.load_transcript(session_entry.session_id)
|
||||
@@ -3540,6 +3593,7 @@ class GatewayRunner:
|
||||
current_base_url = ""
|
||||
current_api_key = ""
|
||||
user_provs = None
|
||||
custom_provs = None
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
try:
|
||||
if config_path.exists():
|
||||
@@ -3551,6 +3605,7 @@ class GatewayRunner:
|
||||
current_provider = model_cfg.get("provider", current_provider)
|
||||
current_base_url = model_cfg.get("base_url", "")
|
||||
user_provs = cfg.get("providers")
|
||||
custom_provs = cfg.get("custom_providers")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -3578,6 +3633,7 @@ class GatewayRunner:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=50,
|
||||
)
|
||||
except Exception:
|
||||
@@ -3605,6 +3661,8 @@ class GatewayRunner:
|
||||
current_api_key=_cur_api_key,
|
||||
is_global=False,
|
||||
explicit_provider=provider_slug,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
)
|
||||
if not result.success:
|
||||
return f"Error: {result.error_message}"
|
||||
@@ -3683,6 +3741,7 @@ class GatewayRunner:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=5,
|
||||
)
|
||||
for p in providers:
|
||||
@@ -3712,6 +3771,8 @@ class GatewayRunner:
|
||||
current_api_key=current_api_key,
|
||||
is_global=persist_global,
|
||||
explicit_provider=explicit_provider,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
@@ -3833,6 +3894,7 @@ class GatewayRunner:
|
||||
|
||||
# Resolve current provider from config
|
||||
current_provider = "openrouter"
|
||||
model_cfg = {}
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
try:
|
||||
if config_path.exists():
|
||||
@@ -4573,6 +4635,7 @@ class GatewayRunner:
|
||||
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
|
||||
reasoning_config = self._load_reasoning_config()
|
||||
self._reasoning_config = reasoning_config
|
||||
self._service_tier = self._load_service_tier()
|
||||
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
|
||||
|
||||
def run_sync():
|
||||
@@ -4584,6 +4647,8 @@ class GatewayRunner:
|
||||
verbose_logging=False,
|
||||
enabled_toolsets=enabled_toolsets,
|
||||
reasoning_config=reasoning_config,
|
||||
service_tier=self._service_tier,
|
||||
request_overrides=turn_route.get("request_overrides"),
|
||||
providers_allowed=pr.get("only"),
|
||||
providers_ignored=pr.get("ignore"),
|
||||
providers_order=pr.get("order"),
|
||||
@@ -4733,6 +4798,7 @@ class GatewayRunner:
|
||||
model = _resolve_gateway_model(user_config)
|
||||
platform_key = _platform_config_key(source.platform)
|
||||
reasoning_config = self._load_reasoning_config()
|
||||
self._service_tier = self._load_service_tier()
|
||||
turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs)
|
||||
pr = self._provider_routing
|
||||
|
||||
@@ -4759,6 +4825,8 @@ class GatewayRunner:
|
||||
verbose_logging=False,
|
||||
enabled_toolsets=[],
|
||||
reasoning_config=reasoning_config,
|
||||
service_tier=self._service_tier,
|
||||
request_overrides=turn_route.get("request_overrides"),
|
||||
providers_allowed=pr.get("only"),
|
||||
providers_ignored=pr.get("ignore"),
|
||||
providers_order=pr.get("order"),
|
||||
@@ -4912,15 +4980,82 @@ class GatewayRunner:
|
||||
else:
|
||||
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass."""
|
||||
current = bool(os.environ.get("HERMES_YOLO_MODE"))
|
||||
if current:
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
return "⚠️ YOLO mode **OFF** — dangerous commands will require approval."
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
|
||||
import yaml
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
args = event.get_command_args().strip().lower()
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
self._service_tier = self._load_service_tier()
|
||||
|
||||
user_config = _load_gateway_config()
|
||||
model = _resolve_gateway_model(user_config)
|
||||
if not model_supports_fast_mode(model):
|
||||
return "⚡ /fast is only available for OpenAI models that support Priority Processing."
|
||||
|
||||
def _save_config_key(key_path: str, value):
|
||||
"""Save a dot-separated key to config.yaml."""
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
keys = key_path.split(".")
|
||||
current = user_config
|
||||
for k in keys[:-1]:
|
||||
if k not in current or not isinstance(current[k], dict):
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
current[keys[-1]] = value
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to save config key %s: %s", key_path, e)
|
||||
return False
|
||||
|
||||
if not args or args == "status":
|
||||
status = "fast" if self._service_tier == "priority" else "normal"
|
||||
return (
|
||||
"⚡ Priority Processing\n\n"
|
||||
f"Current mode: `{status}`\n\n"
|
||||
"_Usage:_ `/fast <normal|fast|status>`"
|
||||
)
|
||||
|
||||
if args in {"fast", "on"}:
|
||||
self._service_tier = "priority"
|
||||
saved_value = "fast"
|
||||
label = "FAST"
|
||||
elif args in {"normal", "off"}:
|
||||
self._service_tier = None
|
||||
saved_value = "normal"
|
||||
label = "NORMAL"
|
||||
else:
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
return "⚡ YOLO mode **ON** — all commands auto-approved. Use with caution."
|
||||
return (
|
||||
f"⚠️ Unknown argument: `{args}`\n\n"
|
||||
"**Valid options:** normal, fast, status"
|
||||
)
|
||||
|
||||
if _save_config_key("agent.service_tier", saved_value):
|
||||
return f"⚡ ✓ Priority Processing: **{label}** (saved to config)\n_(takes effect on next message)_"
|
||||
return f"⚡ ✓ Priority Processing: **{label}** (this session only)"
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
|
||||
from tools.approval import (
|
||||
disable_session_yolo,
|
||||
enable_session_yolo,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
current = is_session_yolo_enabled(session_key)
|
||||
if current:
|
||||
disable_session_yolo(session_key)
|
||||
return "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval."
|
||||
else:
|
||||
enable_session_yolo(session_key)
|
||||
return "⚡ YOLO mode **ON** for this session — all commands auto-approved. Use with caution."
|
||||
|
||||
async def _handle_verbose_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /verbose command — cycle tool progress display mode.
|
||||
@@ -5268,27 +5403,76 @@ class GatewayRunner:
|
||||
)
|
||||
|
||||
async def _handle_usage_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /usage command -- show token usage for the session's last agent run."""
|
||||
"""Handle /usage command -- show token usage for the current session.
|
||||
|
||||
Checks both _running_agents (mid-turn) and _agent_cache (between turns)
|
||||
so that rate limits, cost estimates, and detailed token breakdowns are
|
||||
available whenever the user asks, not only while the agent is running.
|
||||
"""
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
|
||||
# Try running agent first (mid-turn), then cached agent (between turns)
|
||||
agent = self._running_agents.get(session_key)
|
||||
if not agent or agent is _AGENT_PENDING_SENTINEL:
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
cached = _cache.get(session_key)
|
||||
if cached:
|
||||
agent = cached[0]
|
||||
|
||||
if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0:
|
||||
lines = []
|
||||
|
||||
# Rate limits first (when available from provider headers)
|
||||
# Rate limits (when available from provider headers)
|
||||
rl_state = agent.get_rate_limit_state()
|
||||
if rl_state and rl_state.has_data:
|
||||
from agent.rate_limit_tracker import format_rate_limit_compact
|
||||
lines.append(f"⏱️ **Rate Limits:** {format_rate_limit_compact(rl_state)}")
|
||||
lines.append("")
|
||||
|
||||
# Session token usage
|
||||
# Session token usage — detailed breakdown matching CLI
|
||||
input_tokens = getattr(agent, "session_input_tokens", 0) or 0
|
||||
output_tokens = getattr(agent, "session_output_tokens", 0) or 0
|
||||
cache_read = getattr(agent, "session_cache_read_tokens", 0) or 0
|
||||
cache_write = getattr(agent, "session_cache_write_tokens", 0) or 0
|
||||
|
||||
lines.append("📊 **Session Token Usage**")
|
||||
lines.append(f"Prompt (input): {agent.session_prompt_tokens:,}")
|
||||
lines.append(f"Completion (output): {agent.session_completion_tokens:,}")
|
||||
lines.append(f"Model: `{agent.model}`")
|
||||
lines.append(f"Input tokens: {input_tokens:,}")
|
||||
if cache_read:
|
||||
lines.append(f"Cache read tokens: {cache_read:,}")
|
||||
if cache_write:
|
||||
lines.append(f"Cache write tokens: {cache_write:,}")
|
||||
lines.append(f"Output tokens: {output_tokens:,}")
|
||||
lines.append(f"Total: {agent.session_total_tokens:,}")
|
||||
lines.append(f"API calls: {agent.session_api_calls}")
|
||||
|
||||
# Cost estimation
|
||||
try:
|
||||
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
|
||||
cost_result = estimate_usage_cost(
|
||||
agent.model,
|
||||
CanonicalUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_tokens=cache_read,
|
||||
cache_write_tokens=cache_write,
|
||||
),
|
||||
provider=getattr(agent, "provider", None),
|
||||
base_url=getattr(agent, "base_url", None),
|
||||
)
|
||||
if cost_result.amount_usd is not None:
|
||||
prefix = "~" if cost_result.status == "estimated" else ""
|
||||
lines.append(f"Cost: {prefix}${float(cost_result.amount_usd):.4f}")
|
||||
elif cost_result.status == "included":
|
||||
lines.append("Cost: included")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Context window and compressions
|
||||
ctx = agent.context_compressor
|
||||
if ctx.last_prompt_tokens:
|
||||
pct = min(100, ctx.last_prompt_tokens / ctx.context_length * 100) if ctx.context_length else 0
|
||||
@@ -5298,7 +5482,7 @@ class GatewayRunner:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# No running agent -- check session history for a rough count
|
||||
# No agent at all -- check session history for a rough count
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
history = self.session_store.load_transcript(session_entry.session_id)
|
||||
if history:
|
||||
@@ -5309,7 +5493,7 @@ class GatewayRunner:
|
||||
f"📊 **Session Info**\n"
|
||||
f"Messages: {len(msgs)}\n"
|
||||
f"Estimated context: ~{approx:,} tokens\n"
|
||||
f"_(Detailed usage available during active conversations)_"
|
||||
f"_(Detailed usage available after the first agent response)_"
|
||||
)
|
||||
return "No usage data available for this session."
|
||||
|
||||
@@ -6036,16 +6220,14 @@ class GatewayRunner:
|
||||
return f"{disabled_note}\n\n{user_text}"
|
||||
return disabled_note
|
||||
|
||||
from tools.transcription_tools import transcribe_audio, get_stt_model_from_config
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
import asyncio
|
||||
|
||||
stt_model = get_stt_model_from_config()
|
||||
|
||||
enriched_parts = []
|
||||
for path in audio_paths:
|
||||
try:
|
||||
logger.debug("Transcribing user voice: %s", path)
|
||||
result = await asyncio.to_thread(transcribe_audio, path, model=stt_model)
|
||||
result = await asyncio.to_thread(transcribe_audio, path)
|
||||
if result["success"]:
|
||||
transcript = result["transcript"]
|
||||
enriched_parts.append(
|
||||
@@ -6277,6 +6459,32 @@ class GatewayRunner:
|
||||
)
|
||||
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
||||
|
||||
def _apply_session_model_override(
|
||||
self, session_key: str, model: str, runtime_kwargs: dict
|
||||
) -> tuple:
|
||||
"""Apply /model session overrides if present, returning (model, runtime_kwargs).
|
||||
|
||||
The gateway /model command stores per-session overrides in
|
||||
``_session_model_overrides``. These must take precedence over
|
||||
config.yaml defaults so the switched model is actually used for
|
||||
subsequent messages. Fields with ``None`` values are skipped so
|
||||
partial overrides don't clobber valid config defaults.
|
||||
"""
|
||||
override = self._session_model_overrides.get(session_key)
|
||||
if not override:
|
||||
return model, runtime_kwargs
|
||||
model = override.get("model", model)
|
||||
for key in ("provider", "api_key", "base_url", "api_mode"):
|
||||
val = override.get(key)
|
||||
if val is not None:
|
||||
runtime_kwargs[key] = val
|
||||
return model, runtime_kwargs
|
||||
|
||||
def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool:
|
||||
"""Return True if *agent_model* matches an active /model session override."""
|
||||
override = self._session_model_overrides.get(session_key)
|
||||
return override is not None and override.get("model") == agent_model
|
||||
|
||||
def _evict_cached_agent(self, session_key: str) -> None:
|
||||
"""Remove a cached agent for a session (called on /new, /model, etc)."""
|
||||
_lock = getattr(self, "_agent_cache_lock", None)
|
||||
@@ -6654,9 +6862,15 @@ class GatewayRunner:
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
# /model overrides take precedence over config.yaml defaults.
|
||||
model, runtime_kwargs = self._apply_session_model_override(
|
||||
session_key, model, runtime_kwargs
|
||||
)
|
||||
|
||||
pr = self._provider_routing
|
||||
reasoning_config = self._load_reasoning_config()
|
||||
self._reasoning_config = reasoning_config
|
||||
self._service_tier = self._load_service_tier()
|
||||
# Set up streaming consumer if enabled
|
||||
_stream_consumer = None
|
||||
_stream_delta_cb = None
|
||||
@@ -6719,6 +6933,8 @@ class GatewayRunner:
|
||||
ephemeral_system_prompt=combined_ephemeral or None,
|
||||
prefill_messages=self._prefill_messages or None,
|
||||
reasoning_config=reasoning_config,
|
||||
service_tier=self._service_tier,
|
||||
request_overrides=turn_route.get("request_overrides"),
|
||||
providers_allowed=pr.get("only"),
|
||||
providers_ignored=pr.get("ignore"),
|
||||
providers_order=pr.get("order"),
|
||||
@@ -6743,6 +6959,8 @@ class GatewayRunner:
|
||||
agent.stream_delta_callback = _stream_delta_cb
|
||||
agent.status_callback = _status_callback_sync
|
||||
agent.reasoning_config = reasoning_config
|
||||
agent.service_tier = self._service_tier
|
||||
agent.request_overrides = turn_route.get("request_overrides")
|
||||
|
||||
# Background review delivery — send "💾 Memory updated" etc. to user
|
||||
def _bg_review_send(message: str) -> None:
|
||||
@@ -7273,7 +7491,7 @@ class GatewayRunner:
|
||||
_agent = agent_holder[0]
|
||||
if _agent is not None and hasattr(_agent, 'model'):
|
||||
_cfg_model = _resolve_gateway_model()
|
||||
if _agent.model != _cfg_model:
|
||||
if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent.model):
|
||||
# Fallback activated — evict cached agent so the next
|
||||
# message starts fresh and retries the primary model.
|
||||
self._evict_cached_agent(session_key)
|
||||
@@ -7484,7 +7702,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
# setups (each profile using a distinct HERMES_HOME) will naturally
|
||||
# allow concurrent instances without tripping this guard.
|
||||
import time as _time
|
||||
from gateway.status import get_running_pid, remove_pid_file
|
||||
from gateway.status import get_running_pid, remove_pid_file, terminate_pid
|
||||
existing_pid = get_running_pid()
|
||||
if existing_pid is not None and existing_pid != os.getpid():
|
||||
if replace:
|
||||
@@ -7493,10 +7711,10 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
existing_pid,
|
||||
)
|
||||
try:
|
||||
os.kill(existing_pid, signal.SIGTERM)
|
||||
terminate_pid(existing_pid, force=False)
|
||||
except ProcessLookupError:
|
||||
pass # Already gone
|
||||
except PermissionError:
|
||||
except (PermissionError, OSError):
|
||||
logger.error(
|
||||
"Permission denied killing PID %d. Cannot replace.",
|
||||
existing_pid,
|
||||
@@ -7516,9 +7734,9 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
existing_pid,
|
||||
)
|
||||
try:
|
||||
os.kill(existing_pid, signal.SIGKILL)
|
||||
terminate_pid(existing_pid, force=True)
|
||||
_time.sleep(0.5)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
remove_pid_file()
|
||||
# Also release all scoped locks left by the old process.
|
||||
|
||||
@@ -753,41 +753,6 @@ class SessionStore:
|
||||
except Exception as e:
|
||||
print(f"[gateway] Warning: Failed to create SQLite session: {e}")
|
||||
|
||||
# Seed new DM thread sessions with parent DM session history.
|
||||
# When a bot reply creates a Slack thread and the user responds in it,
|
||||
# the thread gets a new session (keyed by thread_ts). Without seeding,
|
||||
# the thread session starts with zero context — the user's original
|
||||
# question and the bot's answer are invisible. Fix: copy the parent
|
||||
# DM session's transcript into the new thread session so context carries
|
||||
# over while still keeping threads isolated from each other.
|
||||
if (
|
||||
source.chat_type == "dm"
|
||||
and source.thread_id
|
||||
and entry.created_at == entry.updated_at # brand-new session
|
||||
and not was_auto_reset
|
||||
):
|
||||
parent_source = SessionSource(
|
||||
platform=source.platform,
|
||||
chat_id=source.chat_id,
|
||||
chat_type="dm",
|
||||
user_id=source.user_id,
|
||||
# no thread_id — this is the parent DM session
|
||||
)
|
||||
parent_key = self._generate_session_key(parent_source)
|
||||
with self._lock:
|
||||
parent_entry = self._entries.get(parent_key)
|
||||
if parent_entry and parent_entry.session_id != entry.session_id:
|
||||
try:
|
||||
parent_history = self.load_transcript(parent_entry.session_id)
|
||||
if parent_history:
|
||||
self.rewrite_transcript(entry.session_id, parent_history)
|
||||
logger.info(
|
||||
"[Session] Seeded DM thread session %s with %d messages from parent %s",
|
||||
entry.session_id, len(parent_history), parent_entry.session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[Session] Failed to seed thread session: %s", e)
|
||||
|
||||
return entry
|
||||
|
||||
def update_session(
|
||||
|
||||
@@ -14,6 +14,8 @@ concurrently under distinct configurations).
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -23,6 +25,7 @@ from typing import Any, Optional
|
||||
_GATEWAY_KIND = "hermes-gateway"
|
||||
_RUNTIME_STATUS_FILE = "gateway_state.json"
|
||||
_LOCKS_DIRNAME = "gateway-locks"
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
|
||||
def _get_pid_path() -> Path:
|
||||
@@ -49,6 +52,33 @@ def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def terminate_pid(pid: int, *, force: bool = False) -> None:
|
||||
"""Terminate a PID with platform-appropriate force semantics.
|
||||
|
||||
POSIX uses SIGTERM/SIGKILL. Windows uses taskkill /T /F for true force-kill
|
||||
because os.kill(..., SIGTERM) is not equivalent to a tree-killing hard stop.
|
||||
"""
|
||||
if force and _IS_WINDOWS:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
return
|
||||
|
||||
if result.returncode != 0:
|
||||
details = (result.stderr or result.stdout or "").strip()
|
||||
raise OSError(details or f"taskkill failed for PID {pid}")
|
||||
return
|
||||
|
||||
sig = signal.SIGTERM if not force else getattr(signal, "SIGKILL", signal.SIGTERM)
|
||||
os.kill(pid, sig)
|
||||
|
||||
|
||||
def _scope_hash(identity: str) -> str:
|
||||
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
@@ -205,11 +205,20 @@ class GatewayStreamConsumer:
|
||||
await self._send_or_edit(self._accumulated)
|
||||
return
|
||||
|
||||
# Tool boundary: the should_edit block above already flushed
|
||||
# accumulated text without a cursor. Reset state so the next
|
||||
# text chunk creates a fresh message below any tool-progress
|
||||
# messages the gateway sent in between.
|
||||
if got_segment_break:
|
||||
# Tool boundary: reset message state so the next text chunk
|
||||
# creates a fresh message below any tool-progress messages.
|
||||
#
|
||||
# Exception: when _message_id is "__no_edit__" the platform
|
||||
# never returned a real message ID (e.g. Signal, webhook with
|
||||
# github_comment delivery). Resetting to None would re-enter
|
||||
# the "first send" path on every tool boundary and post one
|
||||
# platform message per tool call — that is what caused 155
|
||||
# comments under a single PR. Instead, keep all state so the
|
||||
# full continuation is delivered once via _send_fallback_final.
|
||||
# (When editing fails mid-stream due to flood control the id is
|
||||
# a real string like "msg_1", not "__no_edit__", so that case
|
||||
# still resets and creates a fresh segment as intended.)
|
||||
if got_segment_break and self._message_id != "__no_edit__":
|
||||
self._message_id = None
|
||||
self._accumulated = ""
|
||||
self._last_sent_text = ""
|
||||
|
||||
+75
-1
@@ -704,6 +704,27 @@ def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Pa
|
||||
return _save_auth_store(auth_store)
|
||||
|
||||
|
||||
def suppress_credential_source(provider_id: str, source: str) -> None:
|
||||
"""Mark a credential source as suppressed so it won't be re-seeded."""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
suppressed = auth_store.setdefault("suppressed_sources", {})
|
||||
provider_list = suppressed.setdefault(provider_id, [])
|
||||
if source not in provider_list:
|
||||
provider_list.append(source)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def is_source_suppressed(provider_id: str, source: str) -> bool:
|
||||
"""Check if a credential source has been suppressed by the user."""
|
||||
try:
|
||||
auth_store = _load_auth_store()
|
||||
suppressed = auth_store.get("suppressed_sources", {})
|
||||
return source in suppressed.get(provider_id, [])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return persisted auth state for a provider, or None."""
|
||||
auth_store = _load_auth_store()
|
||||
@@ -716,6 +737,57 @@ def get_active_provider() -> Optional[str]:
|
||||
return auth_store.get("active_provider")
|
||||
|
||||
|
||||
def is_provider_explicitly_configured(provider_id: str) -> bool:
|
||||
"""Return True only if the user has explicitly configured this provider.
|
||||
|
||||
Checks:
|
||||
1. active_provider in auth.json matches
|
||||
2. model.provider in config.yaml matches
|
||||
3. Provider-specific env vars are set (e.g. ANTHROPIC_API_KEY)
|
||||
|
||||
This is used to gate auto-discovery of external credentials (e.g.
|
||||
Claude Code's ~/.claude/.credentials.json) so they are never used
|
||||
without the user's explicit choice. See PR #4210 for the same
|
||||
pattern applied to the setup wizard gate.
|
||||
"""
|
||||
normalized = (provider_id or "").strip().lower()
|
||||
|
||||
# 1. Check auth.json active_provider
|
||||
try:
|
||||
auth_store = _load_auth_store()
|
||||
active = (auth_store.get("active_provider") or "").strip().lower()
|
||||
if active and active == normalized:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Check config.yaml model.provider
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
cfg_provider = (model_cfg.get("provider") or "").strip().lower()
|
||||
if cfg_provider == normalized:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Check provider-specific env vars
|
||||
# Exclude CLAUDE_CODE_OAUTH_TOKEN — it's set by Claude Code itself,
|
||||
# not by the user explicitly configuring anthropic in Hermes.
|
||||
_IMPLICIT_ENV_VARS = {"CLAUDE_CODE_OAUTH_TOKEN"}
|
||||
pconfig = PROVIDER_REGISTRY.get(normalized)
|
||||
if pconfig and pconfig.auth_type == "api_key":
|
||||
for env_var in pconfig.api_key_env_vars:
|
||||
if env_var in _IMPLICIT_ENV_VARS:
|
||||
continue
|
||||
if has_usable_secret(os.getenv(env_var, "")):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def clear_provider_auth(provider_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Clear auth state for a provider. Used by `hermes logout`.
|
||||
@@ -2544,6 +2616,8 @@ def _prompt_model_selection(
|
||||
title=effective_title,
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
return None
|
||||
print()
|
||||
@@ -2553,7 +2627,7 @@ def _prompt_model_selection(
|
||||
custom = input("Enter model name: ").strip()
|
||||
return custom if custom else None
|
||||
return None
|
||||
except (ImportError, NotImplementedError):
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# Fallback: numbered list
|
||||
|
||||
@@ -347,8 +347,11 @@ def auth_remove_command(args) -> None:
|
||||
print("Cleared Hermes Anthropic OAuth credentials")
|
||||
|
||||
elif removed.source == "claude_code" and provider == "anthropic":
|
||||
print("Note: Claude Code credentials live in ~/.claude/.credentials.json")
|
||||
print(" Remove them manually if you want to deauthorize Claude Code.")
|
||||
from hermes_cli.auth import suppress_credential_source
|
||||
suppress_credential_source(provider, "claude_code")
|
||||
print("Suppressed claude_code credential — it will not be re-seeded.")
|
||||
print("Note: Claude Code credentials still live in ~/.claude/.credentials.json")
|
||||
print("Run `hermes auth add anthropic` to re-enable if needed.")
|
||||
|
||||
|
||||
def auth_reset_command(args) -> None:
|
||||
|
||||
+35
-5
@@ -16,8 +16,18 @@ from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
# prompt_toolkit is an optional CLI dependency — only needed for
|
||||
# SlashCommandCompleter and SlashCommandAutoSuggest. Gateway and test
|
||||
# environments that lack it must still be able to import this module
|
||||
# for resolve_command, gateway_help_lines, and COMMAND_REGISTRY.
|
||||
try:
|
||||
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
except ImportError: # pragma: no cover
|
||||
AutoSuggest = object # type: ignore[assignment,misc]
|
||||
Completer = object # type: ignore[assignment,misc]
|
||||
Suggestion = None # type: ignore[assignment]
|
||||
Completion = None # type: ignore[assignment]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -73,8 +83,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
args_hint="<question>"),
|
||||
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
|
||||
aliases=("q",), args_hint="<prompt>"),
|
||||
CommandDef("status", "Show session info", "Session",
|
||||
gateway_only=True),
|
||||
CommandDef("status", "Show session info", "Session"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
gateway_only=True, aliases=("set-home",)),
|
||||
@@ -100,6 +109,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
|
||||
args_hint="[level|show|hide]",
|
||||
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||
CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration",
|
||||
args_hint="[normal|fast|status]",
|
||||
subcommands=("normal", "fast", "status", "on", "off")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
@@ -135,6 +147,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
cli_only=True, aliases=("gateway",)),
|
||||
CommandDef("paste", "Check clipboard for an image and attach it", "Info",
|
||||
cli_only=True),
|
||||
CommandDef("image", "Attach a local image file for your next prompt", "Info",
|
||||
cli_only=True, args_hint="<path>"),
|
||||
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
|
||||
gateway_only=True),
|
||||
|
||||
@@ -631,8 +645,18 @@ class SlashCommandCompleter(Completer):
|
||||
def __init__(
|
||||
self,
|
||||
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
||||
command_filter: Callable[[str], bool] | None = None,
|
||||
) -> None:
|
||||
self._skill_commands_provider = skill_commands_provider
|
||||
self._command_filter = command_filter
|
||||
|
||||
def _command_allowed(self, slash_command: str) -> bool:
|
||||
if self._command_filter is None:
|
||||
return True
|
||||
try:
|
||||
return bool(self._command_filter(slash_command))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
|
||||
if self._skill_commands_provider is None:
|
||||
@@ -910,7 +934,7 @@ class SlashCommandCompleter(Completer):
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS:
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||||
yield Completion(
|
||||
@@ -923,6 +947,8 @@ class SlashCommandCompleter(Completer):
|
||||
word = text[1:]
|
||||
|
||||
for cmd, desc in COMMANDS.items():
|
||||
if not self._command_allowed(cmd):
|
||||
continue
|
||||
cmd_name = cmd[1:]
|
||||
if cmd_name.startswith(word):
|
||||
yield Completion(
|
||||
@@ -981,6 +1007,8 @@ class SlashCommandAutoSuggest(AutoSuggest):
|
||||
# Still typing the command name: /upd → suggest "ate"
|
||||
word = text[1:].lower()
|
||||
for cmd in COMMANDS:
|
||||
if self._completer is not None and not self._completer._command_allowed(cmd):
|
||||
continue
|
||||
cmd_name = cmd[1:] # strip leading /
|
||||
if cmd_name.startswith(word) and cmd_name != word:
|
||||
return Suggestion(cmd_name[len(word):])
|
||||
@@ -991,6 +1019,8 @@ class SlashCommandAutoSuggest(AutoSuggest):
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# Static subcommands
|
||||
if self._completer is not None and not self._completer._command_allowed(base_cmd):
|
||||
return None
|
||||
if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
|
||||
if " " not in sub_text:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
|
||||
+74
-3
@@ -158,16 +158,27 @@ def get_project_root() -> Path:
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
def _secure_dir(path):
|
||||
"""Set directory to owner-only access (0700). No-op on Windows.
|
||||
"""Set directory to owner-only access (0700 by default). No-op on Windows.
|
||||
|
||||
Skipped in managed mode — the NixOS module sets group-readable
|
||||
permissions (0750) so interactive users in the hermes group can
|
||||
share state with the gateway service.
|
||||
|
||||
The mode can be overridden via the HERMES_HOME_MODE environment variable
|
||||
(e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx,
|
||||
caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory.
|
||||
The execute-only bit on a directory permits cd-through without exposing
|
||||
directory listings.
|
||||
"""
|
||||
if is_managed():
|
||||
return
|
||||
try:
|
||||
os.chmod(path, 0o700)
|
||||
mode_str = os.environ.get("HERMES_HOME_MODE", "").strip()
|
||||
mode = int(mode_str, 8) if mode_str else 0o700
|
||||
except ValueError:
|
||||
mode = 0o700
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
@@ -255,6 +266,7 @@ DEFAULT_CONFIG = {
|
||||
# tools or receiving API responses. Only fires when the agent has
|
||||
# been completely idle for this duration. 0 = unlimited.
|
||||
"gateway_timeout": 1800,
|
||||
"service_tier": "",
|
||||
# Tool-use enforcement: injects system prompt guidance that tells the
|
||||
# model to actually call tools instead of describing intended actions.
|
||||
# Values: "auto" (default — applies to gpt/codex models), true/false
|
||||
@@ -540,6 +552,7 @@ DEFAULT_CONFIG = {
|
||||
"discord": {
|
||||
"require_mention": True, # Require @mention to respond in server channels
|
||||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||||
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||||
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
|
||||
},
|
||||
@@ -599,7 +612,7 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 13,
|
||||
"_config_version": 14,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -1216,6 +1229,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"API_SERVER_MODEL_NAME": {
|
||||
"description": "Model name advertised on /v1/models. Defaults to the profile name (or 'hermes-agent' for the default profile). Useful for multi-user setups with OpenWebUI.",
|
||||
"prompt": "API server model name",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"WEBHOOK_ENABLED": {
|
||||
"description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
|
||||
"prompt": "Enable webhooks (true/false)",
|
||||
@@ -1746,6 +1767,56 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Version 13 → 14: migrate legacy flat stt.model to provider section ──
|
||||
# Old configs (and cli-config.yaml.example) had a flat `stt.model` key
|
||||
# that was provider-agnostic. When the provider was "local" this caused
|
||||
# OpenAI model names (e.g. "whisper-1") to be fed to faster-whisper,
|
||||
# crashing with "Invalid model size". Move the value into the correct
|
||||
# provider-specific section and remove the flat key.
|
||||
if current_ver < 14:
|
||||
# Read raw config (no defaults merged) to check what the user actually
|
||||
# wrote, then apply changes to the merged config for saving.
|
||||
raw = read_raw_config()
|
||||
raw_stt = raw.get("stt", {})
|
||||
if isinstance(raw_stt, dict) and "model" in raw_stt:
|
||||
legacy_model = raw_stt["model"]
|
||||
provider = raw_stt.get("provider", "local")
|
||||
config = load_config()
|
||||
stt = config.get("stt", {})
|
||||
# Remove the legacy flat key
|
||||
stt.pop("model", None)
|
||||
# Place it in the appropriate provider section only if the
|
||||
# user didn't already set a model there
|
||||
if provider in ("local", "local_command"):
|
||||
# Don't migrate an OpenAI model name into the local section
|
||||
_local_models = {
|
||||
"tiny.en", "tiny", "base.en", "base", "small.en", "small",
|
||||
"medium.en", "medium", "large-v1", "large-v2", "large-v3",
|
||||
"large", "distil-large-v2", "distil-medium.en",
|
||||
"distil-small.en", "distil-large-v3", "distil-large-v3.5",
|
||||
"large-v3-turbo", "turbo",
|
||||
}
|
||||
if legacy_model in _local_models:
|
||||
# Check raw config — only set if user didn't already
|
||||
# have a nested local.model
|
||||
raw_local = raw_stt.get("local", {})
|
||||
if not isinstance(raw_local, dict) or "model" not in raw_local:
|
||||
local_cfg = stt.setdefault("local", {})
|
||||
local_cfg["model"] = legacy_model
|
||||
# else: drop it — it was an OpenAI model name, local section
|
||||
# already defaults to "base" via DEFAULT_CONFIG
|
||||
else:
|
||||
# Cloud provider — put it in that provider's section only
|
||||
# if user didn't already set a nested model
|
||||
raw_provider = raw_stt.get(provider, {})
|
||||
if not isinstance(raw_provider, dict) or "model" not in raw_provider:
|
||||
provider_cfg = stt.setdefault(provider, {})
|
||||
provider_cfg["model"] = legacy_model
|
||||
config["stt"] = stt
|
||||
save_config(config)
|
||||
if not quiet:
|
||||
print(f" ✓ Migrated legacy stt.model to provider-specific config")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ def copilot_request_headers(
|
||||
headers: dict[str, str] = {
|
||||
"Editor-Version": "vscode/1.104.1",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
"Openai-Intent": "conversation-edits",
|
||||
"x-initiator": "agent" if is_agent_turn else "user",
|
||||
}
|
||||
|
||||
@@ -10,6 +10,28 @@ from typing import Callable, List, Optional, Set
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
def flush_stdin() -> None:
|
||||
"""Flush any stray bytes from the stdin input buffer.
|
||||
|
||||
Must be called after ``curses.wrapper()`` (or any terminal-mode library
|
||||
like simple_term_menu) returns, **before** the next ``input()`` /
|
||||
``getpass.getpass()`` call. ``curses.endwin()`` restores the terminal
|
||||
but does NOT drain the OS input buffer — leftover escape-sequence bytes
|
||||
(from arrow keys, terminal mode-switch responses, or rapid keypresses)
|
||||
remain buffered and silently get consumed by the next ``input()`` call,
|
||||
corrupting user data (e.g. writing ``^[^[`` into .env files).
|
||||
|
||||
On non-TTY stdin (piped, redirected) or Windows, this is a no-op.
|
||||
"""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return
|
||||
import termios
|
||||
termios.tcflush(sys.stdin, termios.TCIFLUSH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def curses_checklist(
|
||||
title: str,
|
||||
items: List[str],
|
||||
@@ -131,6 +153,7 @@ def curses_checklist(
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||
|
||||
except Exception:
|
||||
|
||||
+52
-8
@@ -54,6 +54,32 @@ _PROVIDER_ENV_HINTS = (
|
||||
)
|
||||
|
||||
|
||||
from hermes_constants import is_termux as _is_termux
|
||||
|
||||
|
||||
def _python_install_cmd() -> str:
|
||||
return "python -m pip install" if _is_termux() else "uv pip install"
|
||||
|
||||
|
||||
def _system_package_install_cmd(pkg: str) -> str:
|
||||
if _is_termux():
|
||||
return f"pkg install {pkg}"
|
||||
if sys.platform == "darwin":
|
||||
return f"brew install {pkg}"
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
steps: list[str] = []
|
||||
step = 1
|
||||
if not node_installed:
|
||||
steps.append(f"{step}) pkg install nodejs")
|
||||
step += 1
|
||||
steps.append(f"{step}) npm install -g agent-browser")
|
||||
steps.append(f"{step + 1}) agent-browser install")
|
||||
return steps
|
||||
|
||||
|
||||
def _has_provider_env_config(content: str) -> bool:
|
||||
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
|
||||
return any(key in content for key in _PROVIDER_ENV_HINTS)
|
||||
@@ -200,7 +226,7 @@ def run_doctor(args):
|
||||
check_ok(name)
|
||||
except ImportError:
|
||||
check_fail(name, "(missing)")
|
||||
issues.append(f"Install {name}: uv pip install {module}")
|
||||
issues.append(f"Install {name}: {_python_install_cmd()} {module}")
|
||||
|
||||
for module, name in optional_packages:
|
||||
try:
|
||||
@@ -503,7 +529,7 @@ def run_doctor(args):
|
||||
check_ok("ripgrep (rg)", "(faster file search)")
|
||||
else:
|
||||
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
|
||||
check_info("Install for faster search: sudo apt install ripgrep")
|
||||
check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}")
|
||||
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
@@ -526,7 +552,10 @@ def run_doctor(args):
|
||||
if shutil.which("docker"):
|
||||
check_ok("docker", "(optional)")
|
||||
else:
|
||||
check_warn("docker not found", "(optional)")
|
||||
if _is_termux():
|
||||
check_info("Docker backend is not available inside Termux (expected on Android)")
|
||||
else:
|
||||
check_warn("docker not found", "(optional)")
|
||||
|
||||
# SSH (if using ssh backend)
|
||||
if terminal_env == "ssh":
|
||||
@@ -574,9 +603,23 @@ def run_doctor(args):
|
||||
if agent_browser_path.exists():
|
||||
check_ok("agent-browser (Node.js)", "(browser automation)")
|
||||
else:
|
||||
check_warn("agent-browser not installed", "(run: npm install)")
|
||||
if _is_termux():
|
||||
check_info("agent-browser is not installed (expected in the tested Termux path)")
|
||||
check_info("Install it manually later with: npm install -g agent-browser && agent-browser install")
|
||||
check_info("Termux browser setup:")
|
||||
for step in _termux_browser_setup_steps(node_installed=True):
|
||||
check_info(step)
|
||||
else:
|
||||
check_warn("agent-browser not installed", "(run: npm install)")
|
||||
else:
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
if _is_termux():
|
||||
check_info("Node.js not found (browser tools are optional in the tested Termux path)")
|
||||
check_info("Install Node.js on Termux with: pkg install nodejs")
|
||||
check_info("Termux browser setup:")
|
||||
for step in _termux_browser_setup_steps(node_installed=False):
|
||||
check_info(step)
|
||||
else:
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
if shutil.which("npm"):
|
||||
@@ -709,7 +752,7 @@ def run_doctor(args):
|
||||
_url = (_base.rstrip("/") + "/models") if _base else _default_url
|
||||
_headers = {"Authorization": f"Bearer {_key}"}
|
||||
if "api.kimi.com" in _url.lower():
|
||||
_headers["User-Agent"] = "KimiCLI/1.0"
|
||||
_headers["User-Agent"] = "KimiCLI/1.30.0"
|
||||
_resp = httpx.get(
|
||||
_url,
|
||||
headers=_headers,
|
||||
@@ -739,8 +782,9 @@ def run_doctor(args):
|
||||
__import__("tinker_atropos")
|
||||
check_ok("tinker-atropos", "(RL training backend)")
|
||||
except ImportError:
|
||||
check_warn("tinker-atropos found but not installed", "(run: uv pip install -e ./tinker-atropos)")
|
||||
issues.append("Install tinker-atropos: uv pip install -e ./tinker-atropos")
|
||||
install_cmd = f"{_python_install_cmd()} -e ./tinker-atropos"
|
||||
check_warn("tinker-atropos found but not installed", f"(run: {install_cmd})")
|
||||
issues.append(f"Install tinker-atropos: {install_cmd}")
|
||||
else:
|
||||
check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})")
|
||||
else:
|
||||
|
||||
+114
-40
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from gateway.status import terminate_pid
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
|
||||
# display_hermes_home is imported lazily at call sites to avoid ImportError
|
||||
# when hermes_constants is cached from a pre-update version during `hermes update`.
|
||||
@@ -39,7 +40,7 @@ def _get_service_pids() -> set:
|
||||
pids: set = set()
|
||||
|
||||
# --- systemd (Linux): user and system scopes ---
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
for scope_args in [["systemctl", "--user"], ["systemctl"]]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -162,7 +163,7 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None)
|
||||
"""Kill any running gateway processes. Returns count killed.
|
||||
|
||||
Args:
|
||||
force: Use SIGKILL instead of SIGTERM.
|
||||
force: Use the platform's force-kill mechanism instead of graceful terminate.
|
||||
exclude_pids: PIDs to skip (e.g. service-managed PIDs that were just
|
||||
restarted and should not be killed).
|
||||
"""
|
||||
@@ -171,10 +172,7 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None)
|
||||
|
||||
for pid in pids:
|
||||
try:
|
||||
if force and not is_windows():
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
else:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
terminate_pid(pid, force=force)
|
||||
killed += 1
|
||||
except ProcessLookupError:
|
||||
# Process already gone
|
||||
@@ -182,6 +180,8 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None)
|
||||
except PermissionError:
|
||||
print(f"⚠ Permission denied to kill PID {pid}")
|
||||
|
||||
except OSError as exc:
|
||||
print(f"Failed to kill PID {pid}: {exc}")
|
||||
return killed
|
||||
|
||||
|
||||
@@ -225,6 +225,14 @@ def stop_profile_gateway() -> bool:
|
||||
def is_linux() -> bool:
|
||||
return sys.platform.startswith('linux')
|
||||
|
||||
|
||||
from hermes_constants import is_termux
|
||||
|
||||
|
||||
def supports_systemd_services() -> bool:
|
||||
return is_linux() and not is_termux()
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
return sys.platform == 'darwin'
|
||||
|
||||
@@ -475,13 +483,15 @@ def install_linux_gateway_from_setup(force: bool = False) -> tuple[str | None, b
|
||||
|
||||
|
||||
def get_systemd_linger_status() -> tuple[bool | None, str]:
|
||||
"""Return whether systemd user lingering is enabled for the current user.
|
||||
"""Return systemd linger status for the current user.
|
||||
|
||||
Returns:
|
||||
(True, "") when linger is enabled.
|
||||
(False, "") when linger is disabled.
|
||||
(None, detail) when the status could not be determined.
|
||||
"""
|
||||
if is_termux():
|
||||
return None, "not supported in Termux"
|
||||
if not is_linux():
|
||||
return None, "not supported on this platform"
|
||||
|
||||
@@ -595,6 +605,24 @@ def _build_user_local_paths(home: Path, path_entries: list[str]) -> list[str]:
|
||||
return [p for p in candidates if p not in path_entries and Path(p).exists()]
|
||||
|
||||
|
||||
def _remap_path_for_user(path: str, target_home_dir: str) -> str:
|
||||
"""Remap *path* from the current user's home to *target_home_dir*.
|
||||
|
||||
If *path* lives under ``Path.home()`` the corresponding prefix is swapped
|
||||
to *target_home_dir*; otherwise the path is returned unchanged.
|
||||
|
||||
/root/.hermes/hermes-agent -> /home/alice/.hermes/hermes-agent
|
||||
/opt/hermes -> /opt/hermes (kept as-is)
|
||||
"""
|
||||
current_home = Path.home().resolve()
|
||||
resolved = Path(path).resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(current_home)
|
||||
return str(Path(target_home_dir) / relative)
|
||||
except ValueError:
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _hermes_home_for_target_user(target_home_dir: str) -> str:
|
||||
"""Remap the current HERMES_HOME to the equivalent under a target user's home.
|
||||
|
||||
@@ -642,6 +670,15 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
username, group_name, home_dir = _system_service_identity(run_as_user)
|
||||
hermes_home = _hermes_home_for_target_user(home_dir)
|
||||
profile_arg = _profile_arg(hermes_home)
|
||||
# Remap all paths that may resolve under the calling user's home
|
||||
# (e.g. /root/) to the target user's home so the service can
|
||||
# actually access them.
|
||||
python_path = _remap_path_for_user(python_path, home_dir)
|
||||
working_dir = _remap_path_for_user(working_dir, home_dir)
|
||||
venv_dir = _remap_path_for_user(venv_dir, home_dir)
|
||||
venv_bin = _remap_path_for_user(venv_bin, home_dir)
|
||||
node_bin = _remap_path_for_user(node_bin, home_dir)
|
||||
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
|
||||
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
|
||||
path_entries.extend(common_bin_paths)
|
||||
sane_path = ":".join(path_entries)
|
||||
@@ -753,7 +790,7 @@ def _print_linger_enable_warning(username: str, detail: str | None = None) -> No
|
||||
|
||||
def _ensure_linger_enabled() -> None:
|
||||
"""Enable linger when possible so the user gateway survives logout."""
|
||||
if not is_linux():
|
||||
if is_termux() or not is_linux():
|
||||
return
|
||||
|
||||
import getpass
|
||||
@@ -1159,7 +1196,19 @@ def launchd_start():
|
||||
|
||||
def launchd_stop():
|
||||
label = get_launchd_label()
|
||||
subprocess.run(["launchctl", "kill", "SIGTERM", f"{_launchd_domain()}/{label}"], check=True, timeout=30)
|
||||
target = f"{_launchd_domain()}/{label}"
|
||||
# bootout unloads the service definition so KeepAlive doesn't respawn
|
||||
# the process. A plain `kill SIGTERM` only signals the process — launchd
|
||||
# immediately restarts it because KeepAlive.SuccessfulExit = false.
|
||||
# `hermes gateway start` re-bootstraps when it detects the job is unloaded.
|
||||
try:
|
||||
subprocess.run(["launchctl", "bootout", target], check=True, timeout=90)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode in (3, 113):
|
||||
pass # Already unloaded — nothing to stop.
|
||||
else:
|
||||
raise
|
||||
_wait_for_gateway_exit(timeout=10.0, force_after=5.0)
|
||||
print("✓ Service stopped")
|
||||
|
||||
def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
|
||||
@@ -1171,7 +1220,7 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
|
||||
|
||||
Args:
|
||||
timeout: Total seconds to wait before giving up.
|
||||
force_after: Seconds of graceful waiting before sending SIGKILL.
|
||||
force_after: Seconds of graceful waiting before escalating to force-kill.
|
||||
"""
|
||||
import time
|
||||
from gateway.status import get_running_pid
|
||||
@@ -1188,15 +1237,15 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float = 5.0):
|
||||
if not force_sent and time.monotonic() >= force_deadline:
|
||||
# Grace period expired — force-kill the specific PID.
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
terminate_pid(pid, force=True)
|
||||
print(f"⚠ Gateway PID {pid} did not exit gracefully; sent SIGKILL")
|
||||
except (ProcessLookupError, PermissionError):
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
return # Already gone or we can't touch it.
|
||||
force_sent = True
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# Timed out even after SIGKILL.
|
||||
# Timed out even after force-kill.
|
||||
remaining_pid = get_running_pid()
|
||||
if remaining_pid is not None:
|
||||
print(f"⚠ Gateway PID {remaining_pid} still running after {timeout}s — restart may fail")
|
||||
@@ -1750,7 +1799,7 @@ def _setup_standard_platform(platform: dict):
|
||||
print_warning(" Open access enabled — anyone can use your bot!")
|
||||
elif access_idx == 1:
|
||||
print_success(" DM pairing mode — users will receive a code to request access.")
|
||||
print_info(" Approve with: hermes pairing approve {platform} {code}")
|
||||
print_info(" Approve with: hermes pairing approve <platform> <code>")
|
||||
else:
|
||||
print_info(" Skipped — configure later with 'hermes gateway setup'")
|
||||
continue
|
||||
@@ -1788,7 +1837,7 @@ def _setup_whatsapp():
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
return get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()
|
||||
elif is_macos():
|
||||
return get_launchd_plist_path().exists()
|
||||
@@ -1797,7 +1846,7 @@ def _is_service_installed() -> bool:
|
||||
|
||||
def _is_service_running() -> bool:
|
||||
"""Check if the gateway service is currently running."""
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
user_unit_exists = get_systemd_unit_path(system=False).exists()
|
||||
system_unit_exists = get_systemd_unit_path(system=True).exists()
|
||||
|
||||
@@ -1970,7 +2019,7 @@ def gateway_setup():
|
||||
service_installed = _is_service_installed()
|
||||
service_running = _is_service_running()
|
||||
|
||||
if is_linux() and has_conflicting_systemd_units():
|
||||
if supports_systemd_services() and has_conflicting_systemd_units():
|
||||
print_systemd_scope_conflict_warning()
|
||||
print()
|
||||
|
||||
@@ -1980,7 +2029,7 @@ def gateway_setup():
|
||||
print_warning("Gateway service is installed but not running.")
|
||||
if prompt_yes_no(" Start it now?", True):
|
||||
try:
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
@@ -2031,7 +2080,7 @@ def gateway_setup():
|
||||
if service_running:
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
systemd_restart()
|
||||
elif is_macos():
|
||||
launchd_restart()
|
||||
@@ -2043,7 +2092,7 @@ def gateway_setup():
|
||||
elif service_installed:
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
@@ -2051,13 +2100,13 @@ def gateway_setup():
|
||||
print_error(f" Start failed: {e}")
|
||||
else:
|
||||
print()
|
||||
if is_linux() or is_macos():
|
||||
platform_name = "systemd" if is_linux() else "launchd"
|
||||
if supports_systemd_services() or is_macos():
|
||||
platform_name = "systemd" if supports_systemd_services() else "launchd"
|
||||
if prompt_yes_no(f" Install the gateway as a {platform_name} service? (runs in background, starts on boot)", True):
|
||||
try:
|
||||
installed_scope = None
|
||||
did_install = False
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
installed_scope, did_install = install_linux_gateway_from_setup(force=False)
|
||||
else:
|
||||
launchd_install(force=False)
|
||||
@@ -2065,7 +2114,7 @@ def gateway_setup():
|
||||
print()
|
||||
if did_install and prompt_yes_no(" Start the service now?", True):
|
||||
try:
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
systemd_start(system=installed_scope == "system")
|
||||
else:
|
||||
launchd_start()
|
||||
@@ -2076,12 +2125,18 @@ def gateway_setup():
|
||||
print_info(" You can try manually: hermes gateway install")
|
||||
else:
|
||||
print_info(" You can install later: hermes gateway install")
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
print_info(" Or as a boot-time service: sudo hermes gateway install --system")
|
||||
print_info(" Or run in foreground: hermes gateway")
|
||||
else:
|
||||
print_info(" Service install not supported on this platform.")
|
||||
print_info(" Run in foreground: hermes gateway")
|
||||
if is_termux():
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print_info(" Termux does not use systemd/launchd services.")
|
||||
print_info(" Run in foreground: hermes gateway")
|
||||
print_info(f" Or start it manually in the background (best effort): nohup hermes gateway >{_dhh()}/logs/gateway.log 2>&1 &")
|
||||
else:
|
||||
print_info(" Service install not supported on this platform.")
|
||||
print_info(" Run in foreground: hermes gateway")
|
||||
else:
|
||||
print()
|
||||
print_info("No platforms configured. Run 'hermes gateway setup' when ready.")
|
||||
@@ -2117,7 +2172,11 @@ def gateway_command(args):
|
||||
force = getattr(args, 'force', False)
|
||||
system = getattr(args, 'system', False)
|
||||
run_as_user = getattr(args, 'run_as_user', None)
|
||||
if is_linux():
|
||||
if is_termux():
|
||||
print("Gateway service installation is not supported on Termux.")
|
||||
print("Run manually: hermes gateway")
|
||||
sys.exit(1)
|
||||
if supports_systemd_services():
|
||||
systemd_install(force=force, system=system, run_as_user=run_as_user)
|
||||
elif is_macos():
|
||||
launchd_install(force)
|
||||
@@ -2131,7 +2190,11 @@ def gateway_command(args):
|
||||
managed_error("uninstall gateway service (managed by NixOS)")
|
||||
return
|
||||
system = getattr(args, 'system', False)
|
||||
if is_linux():
|
||||
if is_termux():
|
||||
print("Gateway service uninstall is not supported on Termux because there is no managed service to remove.")
|
||||
print("Stop manual runs with: hermes gateway stop")
|
||||
sys.exit(1)
|
||||
if supports_systemd_services():
|
||||
systemd_uninstall(system=system)
|
||||
elif is_macos():
|
||||
launchd_uninstall()
|
||||
@@ -2141,7 +2204,11 @@ def gateway_command(args):
|
||||
|
||||
elif subcmd == "start":
|
||||
system = getattr(args, 'system', False)
|
||||
if is_linux():
|
||||
if is_termux():
|
||||
print("Gateway service start is not supported on Termux because there is no system service manager.")
|
||||
print("Run manually: hermes gateway")
|
||||
sys.exit(1)
|
||||
if supports_systemd_services():
|
||||
systemd_start(system=system)
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
@@ -2156,7 +2223,7 @@ def gateway_command(args):
|
||||
if stop_all:
|
||||
# --all: kill every gateway process on the machine
|
||||
service_available = False
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
try:
|
||||
systemd_stop(system=system)
|
||||
service_available = True
|
||||
@@ -2177,7 +2244,7 @@ def gateway_command(args):
|
||||
else:
|
||||
# Default: stop only the current profile's gateway
|
||||
service_available = False
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
try:
|
||||
systemd_stop(system=system)
|
||||
service_available = True
|
||||
@@ -2205,7 +2272,7 @@ def gateway_command(args):
|
||||
system = getattr(args, 'system', False)
|
||||
service_configured = False
|
||||
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
service_configured = True
|
||||
try:
|
||||
systemd_restart(system=system)
|
||||
@@ -2222,7 +2289,7 @@ def gateway_command(args):
|
||||
|
||||
if not service_available:
|
||||
# systemd/launchd restart failed — check if linger is the issue
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
linger_ok, _detail = get_systemd_linger_status()
|
||||
if linger_ok is not True:
|
||||
import getpass
|
||||
@@ -2259,7 +2326,7 @@ def gateway_command(args):
|
||||
system = getattr(args, 'system', False)
|
||||
|
||||
# Check for service first
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
systemd_status(deep, system=system)
|
||||
elif is_macos() and get_launchd_plist_path().exists():
|
||||
launchd_status(deep)
|
||||
@@ -2276,9 +2343,13 @@ def gateway_command(args):
|
||||
for line in runtime_lines:
|
||||
print(f" {line}")
|
||||
print()
|
||||
print("To install as a service:")
|
||||
print(" hermes gateway install")
|
||||
print(" sudo hermes gateway install --system")
|
||||
if is_termux():
|
||||
print("Termux note:")
|
||||
print(" Android may stop background jobs when Termux is suspended")
|
||||
else:
|
||||
print("To install as a service:")
|
||||
print(" hermes gateway install")
|
||||
print(" sudo hermes gateway install --system")
|
||||
else:
|
||||
print("✗ Gateway is not running")
|
||||
runtime_lines = _runtime_health_lines()
|
||||
@@ -2290,5 +2361,8 @@ def gateway_command(args):
|
||||
print()
|
||||
print("To start:")
|
||||
print(" hermes gateway # Run in foreground")
|
||||
print(" hermes gateway install # Install as user service")
|
||||
print(" sudo hermes gateway install --system # Install as boot-time system service")
|
||||
if is_termux():
|
||||
print(" nohup hermes gateway > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start")
|
||||
else:
|
||||
print(" hermes gateway install # Install as user service")
|
||||
print(" sudo hermes gateway install --system # Install as boot-time system service")
|
||||
|
||||
+60
-49
@@ -646,6 +646,7 @@ def cmd_chat(args):
|
||||
"verbose": args.verbose,
|
||||
"quiet": getattr(args, "quiet", False),
|
||||
"query": args.query,
|
||||
"image": getattr(args, "image", None),
|
||||
"resume": getattr(args, "resume", None),
|
||||
"worktree": getattr(args, "worktree", False),
|
||||
"checkpoints": getattr(args, "checkpoints", False),
|
||||
@@ -857,7 +858,6 @@ def cmd_whatsapp(args):
|
||||
|
||||
def cmd_setup(args):
|
||||
"""Interactive setup wizard."""
|
||||
_require_tty("setup")
|
||||
from hermes_cli.setup import run_setup_wizard
|
||||
run_setup_wizard(args)
|
||||
|
||||
@@ -967,10 +967,11 @@ def select_provider_and_model(args=None):
|
||||
("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
]
|
||||
|
||||
# Add user-defined custom providers from config.yaml
|
||||
custom_providers_cfg = config.get("custom_providers") or []
|
||||
_custom_provider_map = {} # key → {name, base_url, api_key}
|
||||
if isinstance(custom_providers_cfg, list):
|
||||
def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
|
||||
custom_providers_cfg = cfg.get("custom_providers") or []
|
||||
custom_provider_map = {}
|
||||
if not isinstance(custom_providers_cfg, list):
|
||||
return custom_provider_map
|
||||
for entry in custom_providers_cfg:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -979,16 +980,23 @@ def select_provider_and_model(args=None):
|
||||
if not name or not base_url:
|
||||
continue
|
||||
key = "custom:" + name.lower().replace(" ", "-")
|
||||
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
saved_model = entry.get("model", "")
|
||||
model_hint = f" — {saved_model}" if saved_model else ""
|
||||
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
_custom_provider_map[key] = {
|
||||
custom_provider_map[key] = {
|
||||
"name": name,
|
||||
"base_url": base_url,
|
||||
"api_key": entry.get("api_key", ""),
|
||||
"model": saved_model,
|
||||
"model": entry.get("model", ""),
|
||||
}
|
||||
return custom_provider_map
|
||||
|
||||
# Add user-defined custom providers from config.yaml
|
||||
_custom_provider_map = _named_custom_provider_map(config) # key → {name, base_url, api_key}
|
||||
for key, provider_info in _custom_provider_map.items():
|
||||
name = provider_info["name"]
|
||||
base_url = provider_info["base_url"]
|
||||
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
saved_model = provider_info.get("model", "")
|
||||
model_hint = f" — {saved_model}" if saved_model else ""
|
||||
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
|
||||
top_keys = {k for k, _ in top_providers}
|
||||
extended_keys = {k for k, _ in extended_providers}
|
||||
@@ -1053,8 +1061,15 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_copilot(config, current_model)
|
||||
elif selected_provider == "custom":
|
||||
_model_flow_custom(config)
|
||||
elif selected_provider.startswith("custom:") and selected_provider in _custom_provider_map:
|
||||
_model_flow_named_custom(config, _custom_provider_map[selected_provider])
|
||||
elif selected_provider.startswith("custom:"):
|
||||
provider_info = _named_custom_provider_map(load_config()).get(selected_provider)
|
||||
if provider_info is None:
|
||||
print(
|
||||
"Warning: the selected saved custom provider is no longer available. "
|
||||
"It may have been removed from config.yaml. No change."
|
||||
)
|
||||
return
|
||||
_model_flow_named_custom(config, provider_info)
|
||||
elif selected_provider == "remove-custom":
|
||||
_remove_custom_provider(config)
|
||||
elif selected_provider == "anthropic":
|
||||
@@ -1127,10 +1142,10 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
print()
|
||||
|
||||
from hermes_cli.models import model_ids, get_pricing_for_provider
|
||||
openrouter_models = model_ids()
|
||||
openrouter_models = model_ids(force_refresh=True)
|
||||
|
||||
# Fetch live pricing (non-blocking — returns empty dict on failure)
|
||||
pricing = get_pricing_for_provider("openrouter")
|
||||
pricing = get_pricing_for_provider("openrouter", force_refresh=True)
|
||||
|
||||
selected = _prompt_model_selection(openrouter_models, current_model=current_model, pricing=pricing)
|
||||
if selected:
|
||||
@@ -1657,8 +1672,10 @@ def _remove_custom_provider(config):
|
||||
title="Select provider to remove:",
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
print()
|
||||
except (ImportError, NotImplementedError):
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
for i, c in enumerate(choices, 1):
|
||||
print(f" {i}. {c}")
|
||||
print()
|
||||
@@ -1734,12 +1751,14 @@ def _model_flow_named_custom(config, provider_info):
|
||||
title=f"Select model from {name}:",
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
print()
|
||||
if idx is None or idx >= len(models):
|
||||
print("Cancelled.")
|
||||
return
|
||||
model_name = models[idx]
|
||||
except (ImportError, NotImplementedError):
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
for i, m in enumerate(models, 1):
|
||||
print(f" {i}. {m}")
|
||||
print(f" {len(models) + 1}. Cancel")
|
||||
@@ -1852,6 +1871,8 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
|
||||
title="Select reasoning effort:",
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
return None
|
||||
print()
|
||||
@@ -1860,7 +1881,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
|
||||
if idx == len(ordered):
|
||||
return "none"
|
||||
return None
|
||||
except (ImportError, NotImplementedError):
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
print("Select reasoning effort:")
|
||||
@@ -3021,33 +3042,19 @@ def _restore_stashed_changes(
|
||||
print("\nYour stashed changes are preserved — nothing is lost.")
|
||||
print(f" Stash ref: {stash_ref}")
|
||||
|
||||
# Ask before resetting (if interactive)
|
||||
do_reset = True
|
||||
if prompt_user:
|
||||
print("\nReset working tree to clean state so Hermes can run?")
|
||||
print(" (You can re-apply your changes later with: git stash apply)")
|
||||
print("[Y/n] ", end="", flush=True)
|
||||
response = input().strip().lower()
|
||||
if response not in ("", "y", "yes"):
|
||||
do_reset = False
|
||||
|
||||
if do_reset:
|
||||
subprocess.run(
|
||||
git_cmd + ["reset", "--hard", "HEAD"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
)
|
||||
print("Working tree reset to clean state.")
|
||||
else:
|
||||
print("Working tree left as-is (may have conflict markers).")
|
||||
print("Resolve conflicts manually, then run: git stash drop")
|
||||
|
||||
print(f"Restore your changes with: git stash apply {stash_ref}")
|
||||
# In non-interactive mode (gateway /update), don't abort — the code
|
||||
# update itself succeeded, only the stash restore had conflicts.
|
||||
# Aborting would report the entire update as failed.
|
||||
if prompt_user:
|
||||
sys.exit(1)
|
||||
# Always reset to clean state — leaving conflict markers in source
|
||||
# files makes hermes completely unrunnable (SyntaxError on import).
|
||||
# The user's changes are safe in the stash for manual recovery.
|
||||
subprocess.run(
|
||||
git_cmd + ["reset", "--hard", "HEAD"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
)
|
||||
print("Working tree reset to clean state.")
|
||||
print(f"Restore your changes later with: git stash apply {stash_ref}")
|
||||
# Don't sys.exit — the code update itself succeeded, only the stash
|
||||
# restore had conflicts. Let cmd_update continue with pip install,
|
||||
# skill sync, and gateway restart.
|
||||
return False
|
||||
|
||||
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
||||
@@ -3763,7 +3770,7 @@ def cmd_update(args):
|
||||
# running gateway needs restarting to pick up the new code.
|
||||
try:
|
||||
from hermes_cli.gateway import (
|
||||
is_macos, is_linux, _ensure_user_systemd_env,
|
||||
is_macos, supports_systemd_services, _ensure_user_systemd_env,
|
||||
find_gateway_pids,
|
||||
_get_service_pids,
|
||||
)
|
||||
@@ -3774,7 +3781,7 @@ def cmd_update(args):
|
||||
|
||||
# --- Systemd services (Linux) ---
|
||||
# Discover all hermes-gateway* units (default + profiles)
|
||||
if is_linux():
|
||||
if supports_systemd_services():
|
||||
try:
|
||||
_ensure_user_systemd_env()
|
||||
except Exception:
|
||||
@@ -4291,6 +4298,10 @@ For more help on a command:
|
||||
"-q", "--query",
|
||||
help="Single query (non-interactive mode)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--image",
|
||||
help="Optional local image path to attach to a single query"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"-m", "--model",
|
||||
help="Model to use (e.g., anthropic/claude-sonnet-4)"
|
||||
@@ -4481,12 +4492,12 @@ For more help on a command:
|
||||
"setup",
|
||||
help="Interactive setup wizard",
|
||||
description="Configure Hermes Agent with an interactive wizard. "
|
||||
"Run a specific section: hermes setup model|terminal|gateway|tools|agent"
|
||||
"Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent"
|
||||
)
|
||||
setup_parser.add_argument(
|
||||
"section",
|
||||
nargs="?",
|
||||
choices=["model", "terminal", "gateway", "tools", "agent"],
|
||||
choices=["model", "tts", "terminal", "gateway", "tools", "agent"],
|
||||
default=None,
|
||||
help="Run a specific setup section instead of the full wizard"
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ from dataclasses import dataclass
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
from hermes_cli.providers import (
|
||||
custom_provider_slug,
|
||||
determine_api_mode,
|
||||
get_label,
|
||||
is_aggregator,
|
||||
@@ -336,6 +337,7 @@ def resolve_alias(
|
||||
def get_authenticated_provider_slugs(
|
||||
current_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
) -> list[str]:
|
||||
"""Return slugs of providers that have credentials.
|
||||
|
||||
@@ -346,6 +348,7 @@ def get_authenticated_provider_slugs(
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=0,
|
||||
)
|
||||
return [p["slug"] for p in providers]
|
||||
@@ -383,6 +386,7 @@ def switch_model(
|
||||
is_global: bool = False,
|
||||
explicit_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
) -> ModelSwitchResult:
|
||||
"""Core model-switching pipeline shared between CLI and gateway.
|
||||
|
||||
@@ -416,6 +420,7 @@ def switch_model(
|
||||
is_global: Whether to persist the switch.
|
||||
explicit_provider: From --provider flag (empty = no explicit provider).
|
||||
user_providers: The ``providers:`` dict from config.yaml (for user endpoints).
|
||||
custom_providers: The ``custom_providers:`` list from config.yaml.
|
||||
|
||||
Returns:
|
||||
ModelSwitchResult with all information the caller needs.
|
||||
@@ -436,7 +441,11 @@ def switch_model(
|
||||
# =================================================================
|
||||
if explicit_provider:
|
||||
# Resolve the provider
|
||||
pdef = resolve_provider_full(explicit_provider, user_providers)
|
||||
pdef = resolve_provider_full(
|
||||
explicit_provider,
|
||||
user_providers,
|
||||
custom_providers,
|
||||
)
|
||||
if pdef is None:
|
||||
_switch_err = (
|
||||
f"Unknown provider '{explicit_provider}'. "
|
||||
@@ -516,6 +525,7 @@ def switch_model(
|
||||
authed = get_authenticated_provider_slugs(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
)
|
||||
fallback_result = _resolve_alias_fallback(raw_input, authed)
|
||||
if fallback_result is not None:
|
||||
@@ -590,6 +600,14 @@ def switch_model(
|
||||
|
||||
provider_changed = target_provider != current_provider
|
||||
provider_label = get_label(target_provider)
|
||||
if target_provider.startswith("custom:"):
|
||||
custom_pdef = resolve_provider_full(
|
||||
target_provider,
|
||||
user_providers,
|
||||
custom_providers,
|
||||
)
|
||||
if custom_pdef is not None:
|
||||
provider_label = custom_pdef.name
|
||||
|
||||
# --- Resolve credentials ---
|
||||
api_key = current_api_key
|
||||
@@ -708,6 +726,7 @@ def switch_model(
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int = 8,
|
||||
) -> List[dict]:
|
||||
"""Detect which providers have credentials and list their curated models.
|
||||
@@ -853,6 +872,43 @@ def list_authenticated_providers(
|
||||
"api_url": api_url,
|
||||
})
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
display_name = (entry.get("name") or "").strip()
|
||||
api_url = (
|
||||
entry.get("base_url", "")
|
||||
or entry.get("url", "")
|
||||
or entry.get("api", "")
|
||||
or ""
|
||||
).strip()
|
||||
if not display_name or not api_url:
|
||||
continue
|
||||
|
||||
slug = custom_provider_slug(display_name)
|
||||
if slug in seen_slugs:
|
||||
continue
|
||||
|
||||
models_list = []
|
||||
default_model = (entry.get("model") or "").strip()
|
||||
if default_model:
|
||||
models_list.append(default_model)
|
||||
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"is_current": slug == current_provider,
|
||||
"is_user_defined": True,
|
||||
"models": models_list,
|
||||
"total_models": len(models_list),
|
||||
"source": "user-config",
|
||||
"api_url": api_url,
|
||||
})
|
||||
seen_slugs.add(slug)
|
||||
|
||||
# Sort: current provider first, then by model count descending
|
||||
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
|
||||
|
||||
|
||||
+168
-12
@@ -20,18 +20,20 @@ COPILOT_EDITOR_VERSION = "vscode/1.104.1"
|
||||
COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"]
|
||||
COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
||||
|
||||
|
||||
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
|
||||
# (model_id, display description shown in menus)
|
||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("anthropic/claude-opus-4.6", "recommended"),
|
||||
("anthropic/claude-sonnet-4.6", ""),
|
||||
("qwen/qwen3.6-plus:free", "free"),
|
||||
("qwen/qwen3.6-plus", ""),
|
||||
("anthropic/claude-sonnet-4.5", ""),
|
||||
("anthropic/claude-haiku-4.5", ""),
|
||||
("openai/gpt-5.4", ""),
|
||||
("openai/gpt-5.4-mini", ""),
|
||||
("xiaomi/mimo-v2-pro", ""),
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("google/gemini-3-pro-preview", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3-flash-preview", ""),
|
||||
("google/gemini-3.1-pro-preview", ""),
|
||||
("google/gemini-3.1-flash-lite-preview", ""),
|
||||
@@ -43,7 +45,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("z-ai/glm-5.1", ""),
|
||||
("z-ai/glm-5-turbo", ""),
|
||||
("moonshotai/kimi-k2.5", ""),
|
||||
("x-ai/grok-4.20-beta", ""),
|
||||
("x-ai/grok-4.20", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
||||
("arcee-ai/trinity-large-preview:free", "free"),
|
||||
@@ -52,6 +54,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("openai/gpt-5.4-nano", ""),
|
||||
]
|
||||
|
||||
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
|
||||
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"anthropic/claude-opus-4.6",
|
||||
@@ -520,9 +524,82 @@ _PROVIDER_ALIASES = {
|
||||
}
|
||||
|
||||
|
||||
def model_ids() -> list[str]:
|
||||
def _openrouter_model_is_free(pricing: Any) -> bool:
|
||||
"""Return True when both prompt and completion pricing are zero."""
|
||||
if not isinstance(pricing, dict):
|
||||
return False
|
||||
try:
|
||||
return float(pricing.get("prompt", "0")) == 0 and float(pricing.get("completion", "0")) == 0
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def fetch_openrouter_models(
|
||||
timeout: float = 8.0,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Return the curated OpenRouter picker list, refreshed from the live catalog when possible."""
|
||||
global _openrouter_catalog_cache
|
||||
|
||||
if _openrouter_catalog_cache is not None and not force_refresh:
|
||||
return list(_openrouter_catalog_cache)
|
||||
|
||||
fallback = list(OPENROUTER_MODELS)
|
||||
preferred_ids = [mid for mid, _ in fallback]
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
return list(_openrouter_catalog_cache or fallback)
|
||||
|
||||
live_items = payload.get("data", [])
|
||||
if not isinstance(live_items, list):
|
||||
return list(_openrouter_catalog_cache or fallback)
|
||||
|
||||
live_by_id: dict[str, dict[str, Any]] = {}
|
||||
for item in live_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mid = str(item.get("id") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
live_by_id[mid] = item
|
||||
|
||||
curated: list[tuple[str, str]] = []
|
||||
for preferred_id in preferred_ids:
|
||||
live_item = live_by_id.get(preferred_id)
|
||||
if live_item is None:
|
||||
continue
|
||||
desc = "free" if _openrouter_model_is_free(live_item.get("pricing")) else ""
|
||||
curated.append((preferred_id, desc))
|
||||
|
||||
if not curated:
|
||||
return list(_openrouter_catalog_cache or fallback)
|
||||
|
||||
first_id, _ = curated[0]
|
||||
curated[0] = (first_id, "recommended")
|
||||
_openrouter_catalog_cache = curated
|
||||
return list(curated)
|
||||
|
||||
|
||||
def model_ids(*, force_refresh: bool = False) -> list[str]:
|
||||
"""Return just the OpenRouter model-id strings."""
|
||||
return [mid for mid, _ in OPENROUTER_MODELS]
|
||||
return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
|
||||
|
||||
|
||||
def menu_labels(*, force_refresh: bool = False) -> list[str]:
|
||||
"""Return display labels like 'anthropic/claude-opus-4.6 (recommended)'."""
|
||||
labels = []
|
||||
for mid, desc in fetch_openrouter_models(force_refresh=force_refresh):
|
||||
labels.append(f"{mid} ({desc})" if desc else mid)
|
||||
return labels
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -684,13 +761,14 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
|
||||
return ("", "")
|
||||
|
||||
|
||||
def get_pricing_for_provider(provider: str) -> dict[str, dict[str, str]]:
|
||||
def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
|
||||
"""Return live pricing for providers that support it (openrouter, nous)."""
|
||||
normalized = normalize_provider(provider)
|
||||
if normalized == "openrouter":
|
||||
return fetch_models_with_pricing(
|
||||
api_key=_resolve_openrouter_api_key(),
|
||||
base_url="https://openrouter.ai/api",
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
if normalized == "nous":
|
||||
api_key, base_url = _resolve_nous_pricing_credentials()
|
||||
@@ -703,6 +781,7 @@ def get_pricing_for_provider(provider: str) -> dict[str, dict[str, str]]:
|
||||
return fetch_models_with_pricing(
|
||||
api_key=api_key,
|
||||
base_url=stripped,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
return {}
|
||||
|
||||
@@ -811,7 +890,11 @@ def _get_custom_base_url() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]]:
|
||||
def curated_models_for_provider(
|
||||
provider: Optional[str],
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Return ``(model_id, description)`` tuples for a provider's model list.
|
||||
|
||||
Tries to fetch the live model list from the provider's API first,
|
||||
@@ -820,7 +903,7 @@ def curated_models_for_provider(provider: Optional[str]) -> list[tuple[str, str]
|
||||
"""
|
||||
normalized = normalize_provider(provider)
|
||||
if normalized == "openrouter":
|
||||
return list(OPENROUTER_MODELS)
|
||||
return fetch_openrouter_models(force_refresh=force_refresh)
|
||||
|
||||
# Try live API first (Codex, Nous, etc. all support /models)
|
||||
live = provider_model_ids(normalized)
|
||||
@@ -939,12 +1022,12 @@ def _find_openrouter_slug(model_name: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
# Exact match (already has provider/ prefix)
|
||||
for mid, _ in OPENROUTER_MODELS:
|
||||
for mid in model_ids():
|
||||
if name_lower == mid.lower():
|
||||
return mid
|
||||
|
||||
# Try matching just the model part (after the /)
|
||||
for mid, _ in OPENROUTER_MODELS:
|
||||
for mid in model_ids():
|
||||
if "/" in mid:
|
||||
_, model_part = mid.split("/", 1)
|
||||
if name_lower == model_part.lower():
|
||||
@@ -974,6 +1057,79 @@ def provider_label(provider: Optional[str]) -> str:
|
||||
return _PROVIDER_LABELS.get(normalized, original or "OpenRouter")
|
||||
|
||||
|
||||
# Models that support OpenAI Priority Processing (service_tier="priority").
|
||||
# See https://openai.com/api-priority-processing/ for the canonical list.
|
||||
# Only the bare model slug is stored (no vendor prefix).
|
||||
_PRIORITY_PROCESSING_MODELS: frozenset[str] = frozenset({
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.2",
|
||||
"gpt-5.1",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
})
|
||||
|
||||
# Models that support Anthropic Fast Mode (speed="fast").
|
||||
# See https://platform.claude.com/docs/en/build-with-claude/fast-mode
|
||||
# Currently only Claude Opus 4.6. Both hyphen and dot variants are stored
|
||||
# to handle native Anthropic (claude-opus-4-6) and OpenRouter (claude-opus-4.6).
|
||||
_ANTHROPIC_FAST_MODE_MODELS: frozenset[str] = frozenset({
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4.6",
|
||||
})
|
||||
|
||||
|
||||
def _strip_vendor_prefix(model_id: str) -> str:
|
||||
"""Strip vendor/ prefix from a model ID (e.g. 'anthropic/claude-opus-4-6' -> 'claude-opus-4-6')."""
|
||||
raw = str(model_id or "").strip().lower()
|
||||
if "/" in raw:
|
||||
raw = raw.split("/", 1)[1]
|
||||
return raw
|
||||
|
||||
|
||||
def model_supports_fast_mode(model_id: Optional[str]) -> bool:
|
||||
"""Return whether Hermes should expose the /fast toggle for this model."""
|
||||
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||
if raw in _PRIORITY_PROCESSING_MODELS:
|
||||
return True
|
||||
# Anthropic fast mode — strip date suffixes (e.g. claude-opus-4-6-20260401)
|
||||
# and OpenRouter variant tags (:fast, :beta) for matching.
|
||||
base = raw.split(":")[0]
|
||||
return base in _ANTHROPIC_FAST_MODE_MODELS
|
||||
|
||||
|
||||
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
|
||||
"""Return True if the model supports Anthropic's fast mode (speed='fast')."""
|
||||
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||
base = raw.split(":")[0]
|
||||
return base in _ANTHROPIC_FAST_MODE_MODELS
|
||||
|
||||
|
||||
def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | None:
|
||||
"""Return request_overrides for fast/priority mode, or None if unsupported.
|
||||
|
||||
Returns provider-appropriate overrides:
|
||||
- OpenAI models: ``{"service_tier": "priority"}`` (Priority Processing)
|
||||
- Anthropic models: ``{"speed": "fast"}`` (Anthropic Fast Mode beta)
|
||||
|
||||
The overrides are injected into the API request kwargs by
|
||||
``_build_api_kwargs`` in run_agent.py — each API path handles its own
|
||||
keys (service_tier for OpenAI/Codex, speed for Anthropic Messages).
|
||||
"""
|
||||
if not model_supports_fast_mode(model_id):
|
||||
return None
|
||||
if _is_anthropic_fast_model(model_id):
|
||||
return {"speed": "fast"}
|
||||
return {"service_tier": "priority"}
|
||||
|
||||
|
||||
def _resolve_copilot_catalog_api_key() -> str:
|
||||
"""Best-effort GitHub token for fetching the Copilot model catalog."""
|
||||
try:
|
||||
@@ -985,7 +1141,7 @@ def _resolve_copilot_catalog_api_key() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def provider_model_ids(provider: Optional[str]) -> list[str]:
|
||||
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
|
||||
"""Return the best known model catalog for a provider.
|
||||
|
||||
Tries live API endpoints for providers that support them (Codex, Nous),
|
||||
@@ -993,7 +1149,7 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
|
||||
"""
|
||||
normalized = normalize_provider(provider)
|
||||
if normalized == "openrouter":
|
||||
return model_ids()
|
||||
return model_ids(force_refresh=force_refresh)
|
||||
if normalized == "openai-codex":
|
||||
from hermes_cli.codex_models import get_codex_model_ids
|
||||
|
||||
|
||||
@@ -411,9 +411,64 @@ def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[Pr
|
||||
)
|
||||
|
||||
|
||||
def custom_provider_slug(display_name: str) -> str:
|
||||
"""Build a canonical slug for a custom_providers entry.
|
||||
|
||||
Matches the convention used by runtime_provider and credential_pool
|
||||
(``custom:<normalized-name>``). Centralised here so all call-sites
|
||||
produce identical slugs.
|
||||
"""
|
||||
return "custom:" + display_name.strip().lower().replace(" ", "-")
|
||||
|
||||
|
||||
def resolve_custom_provider(
|
||||
name: str,
|
||||
custom_providers: Optional[List[Dict[str, Any]]],
|
||||
) -> Optional[ProviderDef]:
|
||||
"""Resolve a provider from the user's config.yaml ``custom_providers`` list."""
|
||||
if not custom_providers or not isinstance(custom_providers, list):
|
||||
return None
|
||||
|
||||
requested = (name or "").strip().lower()
|
||||
if not requested:
|
||||
return None
|
||||
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
display_name = (entry.get("name") or "").strip()
|
||||
api_url = (
|
||||
entry.get("base_url", "")
|
||||
or entry.get("url", "")
|
||||
or entry.get("api", "")
|
||||
or ""
|
||||
).strip()
|
||||
if not display_name or not api_url:
|
||||
continue
|
||||
|
||||
slug = custom_provider_slug(display_name)
|
||||
if requested not in {display_name.lower(), slug}:
|
||||
continue
|
||||
|
||||
return ProviderDef(
|
||||
id=slug,
|
||||
name=display_name,
|
||||
transport="openai_chat",
|
||||
api_key_env_vars=(),
|
||||
base_url=api_url,
|
||||
is_aggregator=False,
|
||||
auth_type="api_key",
|
||||
source="user-config",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_provider_full(
|
||||
name: str,
|
||||
user_providers: Optional[Dict[str, Any]] = None,
|
||||
custom_providers: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[ProviderDef]:
|
||||
"""Full resolution chain: built-in → models.dev → user config.
|
||||
|
||||
@@ -422,6 +477,7 @@ def resolve_provider_full(
|
||||
Args:
|
||||
name: Provider name or alias.
|
||||
user_providers: The ``providers:`` dict from config.yaml (optional).
|
||||
custom_providers: The ``custom_providers:`` list from config.yaml (optional).
|
||||
|
||||
Returns:
|
||||
ProviderDef if found, else None.
|
||||
@@ -444,6 +500,11 @@ def resolve_provider_full(
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
# 2b. Saved custom providers from config
|
||||
custom_pdef = resolve_custom_provider(name, custom_providers)
|
||||
if custom_pdef is not None:
|
||||
return custom_pdef
|
||||
|
||||
# 3. Try models.dev directly (for providers not in our ALIASES)
|
||||
try:
|
||||
from agent.models_dev import get_provider_info as _mdev_provider
|
||||
|
||||
@@ -16,6 +16,7 @@ from hermes_cli.auth import (
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
DEFAULT_QWEN_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
_agent_key_is_usable,
|
||||
format_auth_error,
|
||||
resolve_provider,
|
||||
resolve_nous_runtime_credentials,
|
||||
@@ -644,6 +645,21 @@ def resolve_runtime_provider(
|
||||
getattr(entry, "runtime_api_key", None)
|
||||
or getattr(entry, "access_token", "")
|
||||
)
|
||||
# For Nous, the pool entry's runtime_api_key is the agent_key — a
|
||||
# short-lived inference credential (~30 min TTL). The pool doesn't
|
||||
# refresh it during selection (that would trigger network calls in
|
||||
# non-runtime contexts like `hermes auth list`). If the key is
|
||||
# expired, clear pool_api_key so we fall through to
|
||||
# resolve_nous_runtime_credentials() which handles refresh + mint.
|
||||
if provider == "nous" and entry is not None and pool_api_key:
|
||||
min_ttl = max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
|
||||
nous_state = {
|
||||
"agent_key": getattr(entry, "agent_key", None),
|
||||
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
|
||||
}
|
||||
if not _agent_key_is_usable(nous_state, min_ttl):
|
||||
logger.debug("Nous pool entry agent_key expired/missing, falling through to runtime resolution")
|
||||
pool_api_key = ""
|
||||
if entry is not None and pool_api_key:
|
||||
return _resolve_runtime_from_pool_entry(
|
||||
provider=provider,
|
||||
|
||||
+17
-16
@@ -16,6 +16,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
@@ -175,6 +176,7 @@ def _setup_copilot_reasoning_selection(
|
||||
|
||||
# Import config helpers
|
||||
from hermes_cli.config import (
|
||||
DEFAULT_CONFIG,
|
||||
get_hermes_home,
|
||||
get_config_path,
|
||||
get_env_path,
|
||||
@@ -336,6 +338,8 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int
|
||||
return
|
||||
|
||||
curses.wrapper(_curses_menu)
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
return result_holder[0]
|
||||
except Exception:
|
||||
return -1
|
||||
@@ -780,8 +784,10 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
# changes with stale values (#4172).
|
||||
_refreshed = load_config()
|
||||
config["model"] = _refreshed.get("model", config.get("model"))
|
||||
if _refreshed.get("custom_providers"):
|
||||
if "custom_providers" in _refreshed:
|
||||
config["custom_providers"] = _refreshed["custom_providers"]
|
||||
else:
|
||||
config.pop("custom_providers", None)
|
||||
|
||||
# Derive the selected provider for downstream steps (vision setup).
|
||||
selected_provider = None
|
||||
@@ -865,8 +871,6 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
strategy_value = ["fill_first", "round_robin", "random"][strategy_idx]
|
||||
_set_credential_pool_strategy(config, selected_provider, strategy_value)
|
||||
print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}")
|
||||
else:
|
||||
_set_credential_pool_strategy(config, selected_provider, "fill_first")
|
||||
except Exception as exc:
|
||||
logger.debug("Could not configure same-provider fallback in setup: %s", exc)
|
||||
|
||||
@@ -2703,6 +2707,7 @@ def run_setup_wizard(args):
|
||||
Supports full, quick, and section-specific setup:
|
||||
hermes setup — full or quick (auto-detected)
|
||||
hermes setup model — just model/provider
|
||||
hermes setup tts — just text-to-speech
|
||||
hermes setup terminal — just terminal backend
|
||||
hermes setup gateway — just messaging platforms
|
||||
hermes setup tools — just tool configuration
|
||||
@@ -2714,6 +2719,11 @@ def run_setup_wizard(args):
|
||||
return
|
||||
ensure_hermes_home()
|
||||
|
||||
reset_requested = bool(getattr(args, "reset", False))
|
||||
if reset_requested:
|
||||
save_config(copy.deepcopy(DEFAULT_CONFIG))
|
||||
print_success("Configuration reset to defaults.")
|
||||
|
||||
config = load_config()
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
@@ -2814,18 +2824,13 @@ def run_setup_wizard(args):
|
||||
menu_choices = [
|
||||
"Quick Setup - configure missing items only",
|
||||
"Full Setup - reconfigure everything",
|
||||
"---",
|
||||
"Model & Provider",
|
||||
"Terminal Backend",
|
||||
"Messaging Platforms (Gateway)",
|
||||
"Tools",
|
||||
"Agent Settings",
|
||||
"---",
|
||||
"Exit",
|
||||
]
|
||||
|
||||
# Separator indices (not selectable, but prompt_choice doesn't filter them,
|
||||
# so we handle them below)
|
||||
choice = prompt_choice("What would you like to do?", menu_choices, 0)
|
||||
|
||||
if choice == 0:
|
||||
@@ -2835,18 +2840,14 @@ def run_setup_wizard(args):
|
||||
elif choice == 1:
|
||||
# Full setup — fall through to run all sections
|
||||
pass
|
||||
elif choice in (2, 8):
|
||||
# Separator — treat as exit
|
||||
elif choice == 7:
|
||||
print_info("Exiting. Run 'hermes setup' again when ready.")
|
||||
return
|
||||
elif choice == 9:
|
||||
print_info("Exiting. Run 'hermes setup' again when ready.")
|
||||
return
|
||||
elif 3 <= choice <= 7:
|
||||
elif 2 <= choice <= 6:
|
||||
# Individual section — map by key, not by position.
|
||||
# SETUP_SECTIONS includes TTS but the returning-user menu skips it,
|
||||
# so positional indexing (choice - 3) would dispatch the wrong section.
|
||||
section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 3]
|
||||
# so positional indexing (choice - 2) would dispatch the wrong section.
|
||||
section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 2]
|
||||
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
|
||||
if section:
|
||||
_, label, func = section
|
||||
|
||||
+23
-2
@@ -79,6 +79,9 @@ def _effective_provider_label() -> str:
|
||||
return provider_label(effective)
|
||||
|
||||
|
||||
from hermes_constants import is_termux as _is_termux
|
||||
|
||||
|
||||
def show_status(args):
|
||||
"""Show status of all Hermes Agent components."""
|
||||
show_all = getattr(args, 'all', False)
|
||||
@@ -325,7 +328,25 @@ def show_status(args):
|
||||
print()
|
||||
print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
if sys.platform.startswith('linux'):
|
||||
if _is_termux():
|
||||
try:
|
||||
from hermes_cli.gateway import find_gateway_pids
|
||||
gateway_pids = find_gateway_pids()
|
||||
except Exception:
|
||||
gateway_pids = []
|
||||
is_running = bool(gateway_pids)
|
||||
print(f" Status: {check_mark(is_running)} {'running' if is_running else 'stopped'}")
|
||||
print(" Manager: Termux / manual process")
|
||||
if gateway_pids:
|
||||
rendered = ", ".join(str(pid) for pid in gateway_pids[:3])
|
||||
if len(gateway_pids) > 3:
|
||||
rendered += ", ..."
|
||||
print(f" PID(s): {rendered}")
|
||||
else:
|
||||
print(" Start with: hermes gateway")
|
||||
print(" Note: Android may stop background jobs when Termux is suspended")
|
||||
|
||||
elif sys.platform.startswith('linux'):
|
||||
try:
|
||||
from hermes_cli.gateway import get_service_name
|
||||
_gw_svc = get_service_name()
|
||||
@@ -339,7 +360,7 @@ def show_status(args):
|
||||
timeout=5
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
except subprocess.TimeoutExpired:
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
is_active = False
|
||||
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
@@ -720,6 +720,8 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
|
||||
return
|
||||
|
||||
curses.wrapper(_curses_menu)
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
return result_holder[0]
|
||||
|
||||
except Exception:
|
||||
|
||||
@@ -6,6 +6,8 @@ Provides options for:
|
||||
- Keep data: Remove code but keep ~/.hermes/ (configs, sessions, logs)
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -122,6 +124,10 @@ def uninstall_gateway_service():
|
||||
|
||||
if platform.system() != "Linux":
|
||||
return False
|
||||
|
||||
prefix = os.getenv("PREFIX", "")
|
||||
if os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix:
|
||||
return False
|
||||
|
||||
try:
|
||||
from hermes_cli.gateway import get_service_name
|
||||
|
||||
@@ -93,6 +93,16 @@ def parse_reasoning_effort(effort: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_termux() -> bool:
|
||||
"""Return True when running inside a Termux (Android) environment.
|
||||
|
||||
Checks ``TERMUX_VERSION`` (set by Termux) or the Termux-specific
|
||||
``PREFIX`` path. Import-safe — no heavy deps.
|
||||
"""
|
||||
prefix = os.getenv("PREFIX", "")
|
||||
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
||||
|
||||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
||||
|
||||
|
||||
@@ -63,6 +63,17 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
|
||||
sms = ["aiohttp>=3.9.0,<4"]
|
||||
acp = ["agent-client-protocol>=0.9.0,<1.0"]
|
||||
mistral = ["mistralai>=2.3.0,<3"]
|
||||
termux = [
|
||||
# Tested Android / Termux path: keeps the core CLI feature-rich while
|
||||
# avoiding extras that currently depend on non-Android wheels (notably
|
||||
# faster-whisper -> ctranslate2 via the voice extra).
|
||||
"hermes-agent[cron]",
|
||||
"hermes-agent[cli]",
|
||||
"hermes-agent[pty]",
|
||||
"hermes-agent[mcp]",
|
||||
"hermes-agent[honcho]",
|
||||
"hermes-agent[acp]",
|
||||
]
|
||||
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
||||
feishu = ["lark-oapi>=1.5.3,<2"]
|
||||
rl = [
|
||||
|
||||
+225
-47
@@ -500,6 +500,8 @@ class AIAgent:
|
||||
status_callback: callable = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
request_overrides: Dict[str, Any] = None,
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
@@ -622,6 +624,7 @@ class AIAgent:
|
||||
self.tool_progress_callback = tool_progress_callback
|
||||
self.tool_start_callback = tool_start_callback
|
||||
self.tool_complete_callback = tool_complete_callback
|
||||
self.suppress_status_output = False
|
||||
self.thinking_callback = thinking_callback
|
||||
self.reasoning_callback = reasoning_callback
|
||||
self.clarify_callback = clarify_callback
|
||||
@@ -660,6 +663,8 @@ class AIAgent:
|
||||
# Model response configuration
|
||||
self.max_tokens = max_tokens # None = use model default
|
||||
self.reasoning_config = reasoning_config # None = use default (medium for OpenRouter)
|
||||
self.service_tier = service_tier
|
||||
self.request_overrides = dict(request_overrides or {})
|
||||
self.prefill_messages = prefill_messages or [] # Prefilled conversation turns
|
||||
|
||||
# Anthropic prompt caching: auto-enabled for Claude models via OpenRouter.
|
||||
@@ -788,7 +793,7 @@ class AIAgent:
|
||||
client_kwargs["default_headers"] = copilot_default_headers()
|
||||
elif "api.kimi.com" in effective_base.lower():
|
||||
client_kwargs["default_headers"] = {
|
||||
"User-Agent": "KimiCLI/1.3",
|
||||
"User-Agent": "KimiCLI/1.30.0",
|
||||
}
|
||||
elif "portal.qwen.ai" in effective_base.lower():
|
||||
client_kwargs["default_headers"] = _qwen_portal_headers()
|
||||
@@ -1458,7 +1463,14 @@ class AIAgent:
|
||||
After the main response has been delivered and the remaining tool
|
||||
calls are post-response housekeeping (``_mute_post_response``),
|
||||
all non-forced output is suppressed.
|
||||
|
||||
``suppress_status_output`` is a stricter CLI automation mode used by
|
||||
parseable single-query flows such as ``hermes chat -q``. In that mode,
|
||||
all status/diagnostic prints routed through ``_vprint`` are suppressed
|
||||
so stdout stays machine-readable.
|
||||
"""
|
||||
if getattr(self, "suppress_status_output", False):
|
||||
return
|
||||
if not force and getattr(self, "_mute_post_response", False):
|
||||
return
|
||||
if not force and self._has_stream_consumers() and not self._executing_tools:
|
||||
@@ -1484,6 +1496,17 @@ class AIAgent:
|
||||
except (AttributeError, ValueError, OSError):
|
||||
return False
|
||||
|
||||
def _should_emit_quiet_tool_messages(self) -> bool:
|
||||
"""Return True when quiet-mode tool summaries should print directly.
|
||||
|
||||
When the caller provides ``tool_progress_callback`` (for example the CLI
|
||||
TUI or a gateway progress renderer), that callback owns progress display.
|
||||
Emitting quiet-mode summary lines here duplicates progress and leaks tool
|
||||
previews into flows that are expected to stay silent, such as
|
||||
``hermes chat -q``.
|
||||
"""
|
||||
return self.quiet_mode and not self.tool_progress_callback
|
||||
|
||||
def _emit_status(self, message: str) -> None:
|
||||
"""Emit a lifecycle status message to both CLI and gateway channels.
|
||||
|
||||
@@ -3322,7 +3345,7 @@ class AIAgent:
|
||||
allowed_keys = {
|
||||
"model", "instructions", "input", "tools", "store",
|
||||
"reasoning", "include", "max_output_tokens", "temperature",
|
||||
"tool_choice", "parallel_tool_calls", "prompt_cache_key",
|
||||
"tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier",
|
||||
}
|
||||
normalized: Dict[str, Any] = {
|
||||
"model": model,
|
||||
@@ -3340,6 +3363,9 @@ class AIAgent:
|
||||
include = api_kwargs.get("include")
|
||||
if isinstance(include, list):
|
||||
normalized["include"] = include
|
||||
service_tier = api_kwargs.get("service_tier")
|
||||
if isinstance(service_tier, str) and service_tier.strip():
|
||||
normalized["service_tier"] = service_tier.strip()
|
||||
|
||||
# Pass through max_output_tokens and temperature
|
||||
max_output_tokens = api_kwargs.get("max_output_tokens")
|
||||
@@ -4152,7 +4178,7 @@ class AIAgent:
|
||||
|
||||
self._client_kwargs["default_headers"] = copilot_default_headers()
|
||||
elif "api.kimi.com" in normalized:
|
||||
self._client_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
self._client_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.30.0"}
|
||||
elif "portal.qwen.ai" in normalized:
|
||||
self._client_kwargs["default_headers"] = _qwen_portal_headers()
|
||||
else:
|
||||
@@ -4190,49 +4216,80 @@ class AIAgent:
|
||||
*,
|
||||
status_code: Optional[int],
|
||||
has_retried_429: bool,
|
||||
classified_reason: Optional[FailoverReason] = None,
|
||||
error_context: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Attempt credential recovery via pool rotation.
|
||||
|
||||
Returns (recovered, has_retried_429).
|
||||
On 429: first occurrence retries same credential (sets flag True).
|
||||
second consecutive 429 rotates to next credential (resets flag).
|
||||
On 402: immediately rotates (billing exhaustion won't resolve with retry).
|
||||
On 401: attempts token refresh before rotating.
|
||||
On rate limits: first occurrence retries same credential (sets flag True).
|
||||
second consecutive failure rotates to next credential.
|
||||
On billing exhaustion: immediately rotates.
|
||||
On auth failures: attempts token refresh before rotating.
|
||||
|
||||
`classified_reason` lets the recovery path honor the structured error
|
||||
classifier instead of relying only on raw HTTP codes. This matters for
|
||||
providers that surface billing/rate-limit/auth conditions under a
|
||||
different status code, such as Anthropic returning HTTP 400 for
|
||||
"out of extra usage".
|
||||
"""
|
||||
pool = self._credential_pool
|
||||
if pool is None or status_code is None:
|
||||
if pool is None:
|
||||
return False, has_retried_429
|
||||
|
||||
if status_code == 402:
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=402, error_context=error_context)
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
if status_code == 402:
|
||||
effective_reason = FailoverReason.billing
|
||||
elif status_code == 429:
|
||||
effective_reason = FailoverReason.rate_limit
|
||||
elif status_code == 401:
|
||||
effective_reason = FailoverReason.auth
|
||||
|
||||
if effective_reason == FailoverReason.billing:
|
||||
rotate_status = status_code if status_code is not None else 402
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
|
||||
if next_entry is not None:
|
||||
logger.info(f"Credential 402 (billing) — rotated to pool entry {getattr(next_entry, 'id', '?')}")
|
||||
logger.info(
|
||||
"Credential %s (billing) — rotated to pool entry %s",
|
||||
rotate_status,
|
||||
getattr(next_entry, "id", "?"),
|
||||
)
|
||||
self._swap_credential(next_entry)
|
||||
return True, False
|
||||
return False, has_retried_429
|
||||
|
||||
if status_code == 429:
|
||||
if effective_reason == FailoverReason.rate_limit:
|
||||
if not has_retried_429:
|
||||
return False, True
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=429, error_context=error_context)
|
||||
rotate_status = status_code if status_code is not None else 429
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
|
||||
if next_entry is not None:
|
||||
logger.info(f"Credential 429 (rate limit) — rotated to pool entry {getattr(next_entry, 'id', '?')}")
|
||||
logger.info(
|
||||
"Credential %s (rate limit) — rotated to pool entry %s",
|
||||
rotate_status,
|
||||
getattr(next_entry, "id", "?"),
|
||||
)
|
||||
self._swap_credential(next_entry)
|
||||
return True, False
|
||||
return False, True
|
||||
|
||||
if status_code == 401:
|
||||
if effective_reason == FailoverReason.auth:
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is not None:
|
||||
logger.info(f"Credential 401 — refreshed pool entry {getattr(refreshed, 'id', '?')}")
|
||||
logger.info(f"Credential auth failure — refreshed pool entry {getattr(refreshed, 'id', '?')}")
|
||||
self._swap_credential(refreshed)
|
||||
return True, has_retried_429
|
||||
# Refresh failed — rotate to next credential instead of giving up.
|
||||
# The failed entry is already marked exhausted by try_refresh_current().
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=401, error_context=error_context)
|
||||
rotate_status = status_code if status_code is not None else 401
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
|
||||
if next_entry is not None:
|
||||
logger.info(f"Credential 401 (refresh failed) — rotated to pool entry {getattr(next_entry, 'id', '?')}")
|
||||
logger.info(
|
||||
"Credential %s (auth refresh failed) — rotated to pool entry %s",
|
||||
rotate_status,
|
||||
getattr(next_entry, "id", "?"),
|
||||
)
|
||||
self._swap_credential(next_entry)
|
||||
return True, False
|
||||
|
||||
@@ -4403,7 +4460,17 @@ class AIAgent:
|
||||
"""Stream a chat completions response."""
|
||||
import httpx as _httpx
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 60.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
# Local providers (Ollama, llama.cpp, vLLM) can take minutes for
|
||||
# prefill on large contexts before producing the first token.
|
||||
# Auto-increase the httpx read timeout unless the user explicitly
|
||||
# overrode HERMES_STREAM_READ_TIMEOUT.
|
||||
if _stream_read_timeout == 120.0 and self.base_url and is_local_endpoint(self.base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
logger.debug(
|
||||
"Local provider detected (%s) — stream read timeout raised to %.0fs",
|
||||
self.base_url, _stream_read_timeout,
|
||||
)
|
||||
stream_kwargs = {
|
||||
**api_kwargs,
|
||||
"stream": True,
|
||||
@@ -4557,20 +4624,31 @@ class AIAgent:
|
||||
# Build mock response matching non-streaming shape
|
||||
full_content = "".join(content_parts) or None
|
||||
mock_tool_calls = None
|
||||
has_truncated_tool_args = False
|
||||
if tool_calls_acc:
|
||||
mock_tool_calls = []
|
||||
for idx in sorted(tool_calls_acc):
|
||||
tc = tool_calls_acc[idx]
|
||||
arguments = tc["function"]["arguments"]
|
||||
if arguments and arguments.strip():
|
||||
try:
|
||||
json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
has_truncated_tool_args = True
|
||||
mock_tool_calls.append(SimpleNamespace(
|
||||
id=tc["id"],
|
||||
type=tc["type"],
|
||||
extra_content=tc.get("extra_content"),
|
||||
function=SimpleNamespace(
|
||||
name=tc["function"]["name"],
|
||||
arguments=tc["function"]["arguments"],
|
||||
arguments=arguments,
|
||||
),
|
||||
))
|
||||
|
||||
effective_finish_reason = finish_reason or "stop"
|
||||
if has_truncated_tool_args:
|
||||
effective_finish_reason = "length"
|
||||
|
||||
full_reasoning = "".join(reasoning_parts) or None
|
||||
mock_message = SimpleNamespace(
|
||||
role=role,
|
||||
@@ -4581,7 +4659,7 @@ class AIAgent:
|
||||
mock_choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=mock_message,
|
||||
finish_reason=finish_reason or "stop",
|
||||
finish_reason=effective_finish_reason,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id="stream-" + str(uuid.uuid4()),
|
||||
@@ -4605,6 +4683,14 @@ class AIAgent:
|
||||
# Use the Anthropic SDK's streaming context manager
|
||||
with self._anthropic_client.messages.stream(**api_kwargs) as stream:
|
||||
for event in stream:
|
||||
# Update stale-stream timer on every event so the
|
||||
# outer poll loop knows data is flowing. Without
|
||||
# this, the detector kills healthy long-running
|
||||
# Opus streams after 180 s even when events are
|
||||
# actively arriving (the chat_completions path
|
||||
# already does this at the top of its chunk loop).
|
||||
last_chunk_time["t"] = time.time()
|
||||
|
||||
if self._interrupt_requested:
|
||||
break
|
||||
|
||||
@@ -4628,6 +4714,7 @@ class AIAgent:
|
||||
if text and not has_tool_use:
|
||||
_fire_first_delta()
|
||||
self._fire_stream_delta(text)
|
||||
deltas_were_sent["yes"] = True
|
||||
elif delta_type == "thinking_delta":
|
||||
thinking_text = getattr(delta, "thinking", "")
|
||||
if thinking_text:
|
||||
@@ -5087,6 +5174,7 @@ class AIAgent:
|
||||
_TRANSIENT_TRANSPORT_ERRORS = frozenset({
|
||||
"ReadTimeout", "ConnectTimeout", "PoolTimeout",
|
||||
"ConnectError", "RemoteProtocolError",
|
||||
"APIConnectionError", "APITimeoutError",
|
||||
})
|
||||
|
||||
def _try_recover_primary_transport(
|
||||
@@ -5410,6 +5498,7 @@ class AIAgent:
|
||||
preserve_dots=self._anthropic_preserve_dots(),
|
||||
context_length=ctx_len,
|
||||
base_url=getattr(self, "_anthropic_base_url", None),
|
||||
fast_mode=self.request_overrides.get("speed") == "fast",
|
||||
)
|
||||
|
||||
if self.api_mode == "codex_responses":
|
||||
@@ -5425,6 +5514,10 @@ class AIAgent:
|
||||
"models.github.ai" in self.base_url.lower()
|
||||
or "api.githubcopilot.com" in self.base_url.lower()
|
||||
)
|
||||
is_codex_backend = (
|
||||
self.provider == "openai-codex"
|
||||
or "chatgpt.com/backend-api/codex" in self.base_url.lower()
|
||||
)
|
||||
|
||||
# Resolve reasoning effort: config > default (medium)
|
||||
reasoning_effort = "medium"
|
||||
@@ -5462,7 +5555,10 @@ class AIAgent:
|
||||
elif not is_github_responses:
|
||||
kwargs["include"] = []
|
||||
|
||||
if self.max_tokens is not None:
|
||||
if self.request_overrides:
|
||||
kwargs.update(self.request_overrides)
|
||||
|
||||
if self.max_tokens is not None and not is_codex_backend:
|
||||
kwargs["max_output_tokens"] = self.max_tokens
|
||||
|
||||
return kwargs
|
||||
@@ -5557,20 +5653,20 @@ class AIAgent:
|
||||
if self.max_tokens is not None:
|
||||
if not self._is_qwen_portal():
|
||||
api_kwargs.update(self._max_tokens_param(self.max_tokens))
|
||||
elif self._is_openrouter_url() and "claude" in (self.model or "").lower():
|
||||
# OpenRouter translates requests to Anthropic's Messages API,
|
||||
# which requires max_tokens as a mandatory field. When we omit
|
||||
# it, OpenRouter picks a default that can be too low — the model
|
||||
# spends its output budget on thinking and has almost nothing
|
||||
# left for the actual response (especially large tool calls like
|
||||
# write_file). Sending the model's real output limit ensures
|
||||
# full capacity. Other providers handle the default fine.
|
||||
elif (self._is_openrouter_url() or "nousresearch" in self._base_url_lower) and "claude" in (self.model or "").lower():
|
||||
# OpenRouter and Nous Portal translate requests to Anthropic's
|
||||
# Messages API, which requires max_tokens as a mandatory field.
|
||||
# When we omit it, the proxy picks a default that can be too
|
||||
# low — the model spends its output budget on thinking and has
|
||||
# almost nothing left for the actual response (especially large
|
||||
# tool calls like write_file). Sending the model's real output
|
||||
# limit ensures full capacity.
|
||||
try:
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output
|
||||
_model_output_limit = _get_anthropic_max_output(self.model)
|
||||
api_kwargs["max_tokens"] = _model_output_limit
|
||||
except Exception:
|
||||
pass # fail open — let OpenRouter pick its default
|
||||
pass # fail open — let the proxy pick its default
|
||||
|
||||
extra_body = {}
|
||||
|
||||
@@ -5633,6 +5729,11 @@ class AIAgent:
|
||||
if "x.ai" in self._base_url_lower and hasattr(self, "session_id") and self.session_id:
|
||||
api_kwargs["extra_headers"] = {"x-grok-conv-id": self.session_id}
|
||||
|
||||
# Priority Processing / generic request overrides (e.g. service_tier).
|
||||
# Applied last so overrides win over any defaults set above.
|
||||
if self.request_overrides:
|
||||
api_kwargs.update(self.request_overrides)
|
||||
|
||||
return api_kwargs
|
||||
|
||||
def _supports_reasoning_extra_body(self) -> bool:
|
||||
@@ -6338,7 +6439,7 @@ class AIAgent:
|
||||
|
||||
# Start spinner for CLI mode (skip when TUI handles tool progress)
|
||||
spinner = None
|
||||
if self.quiet_mode and not self.tool_progress_callback and self._should_start_quiet_spinner():
|
||||
if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
spinner = KawaiiSpinner(f"{face} ⚡ running {num_tools} tools concurrently", spinner_type='dots', print_fn=self._print_fn)
|
||||
spinner.start()
|
||||
@@ -6388,7 +6489,7 @@ class AIAgent:
|
||||
logging.debug(f"Tool result ({len(function_result)} chars): {function_result}")
|
||||
|
||||
# Print cute message per tool
|
||||
if self.quiet_mode:
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
|
||||
self._safe_print(f" {cute_msg}")
|
||||
elif not self.quiet_mode:
|
||||
@@ -6545,7 +6646,7 @@ class AIAgent:
|
||||
store=self._todo_store,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self.quiet_mode:
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "session_search":
|
||||
if not self._session_db:
|
||||
@@ -6560,7 +6661,7 @@ class AIAgent:
|
||||
current_session_id=self.session_id,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self.quiet_mode:
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {_get_cute_tool_message_impl('session_search', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "memory":
|
||||
target = function_args.get("target", "memory")
|
||||
@@ -6573,7 +6674,7 @@ class AIAgent:
|
||||
store=self._memory_store,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self.quiet_mode:
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "clarify":
|
||||
from tools.clarify_tool import clarify_tool as _clarify_tool
|
||||
@@ -6583,7 +6684,7 @@ class AIAgent:
|
||||
callback=self.clarify_callback,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self.quiet_mode:
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "delegate_task":
|
||||
from tools.delegate_tool import delegate_task as _delegate_task
|
||||
@@ -6594,7 +6695,7 @@ class AIAgent:
|
||||
goal_preview = (function_args.get("goal") or "")[:30]
|
||||
spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating"
|
||||
spinner = None
|
||||
if self.quiet_mode and not self.tool_progress_callback and self._should_start_quiet_spinner():
|
||||
if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
spinner = KawaiiSpinner(f"{face} {spinner_label}", spinner_type='dots', print_fn=self._print_fn)
|
||||
spinner.start()
|
||||
@@ -6616,13 +6717,13 @@ class AIAgent:
|
||||
cute_msg = _get_cute_tool_message_impl('delegate_task', function_args, tool_duration, result=_delegate_result)
|
||||
if spinner:
|
||||
spinner.stop(cute_msg)
|
||||
elif self.quiet_mode:
|
||||
elif self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {cute_msg}")
|
||||
elif self._memory_manager and self._memory_manager.has_tool(function_name):
|
||||
# Memory provider tools (hindsight_retain, honcho_search, etc.)
|
||||
# These are not in the tool registry — route through MemoryManager.
|
||||
spinner = None
|
||||
if self.quiet_mode and not self.tool_progress_callback:
|
||||
if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
@@ -6640,11 +6741,11 @@ class AIAgent:
|
||||
cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_mem_result)
|
||||
if spinner:
|
||||
spinner.stop(cute_msg)
|
||||
elif self.quiet_mode:
|
||||
elif self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {cute_msg}")
|
||||
elif self.quiet_mode:
|
||||
spinner = None
|
||||
if not self.tool_progress_callback:
|
||||
if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
@@ -6667,7 +6768,7 @@ class AIAgent:
|
||||
cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_spinner_result)
|
||||
if spinner:
|
||||
spinner.stop(cute_msg)
|
||||
else:
|
||||
elif self._should_emit_quiet_tool_messages():
|
||||
self._vprint(f" {cute_msg}")
|
||||
else:
|
||||
try:
|
||||
@@ -7291,6 +7392,7 @@ class AIAgent:
|
||||
interrupted = False
|
||||
codex_ack_continuations = 0
|
||||
length_continue_retries = 0
|
||||
truncated_tool_call_retries = 0
|
||||
truncated_response_prefix = ""
|
||||
compression_attempts = 0
|
||||
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
|
||||
@@ -7759,9 +7861,11 @@ class AIAgent:
|
||||
# retries are pointless. Detect this early and give a
|
||||
# targeted error instead of wasting 3 API calls.
|
||||
_trunc_content = None
|
||||
_trunc_has_tool_calls = False
|
||||
if self.api_mode == "chat_completions":
|
||||
_trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None
|
||||
_trunc_content = getattr(_trunc_msg, "content", None) if _trunc_msg else None
|
||||
_trunc_has_tool_calls = bool(getattr(_trunc_msg, "tool_calls", None)) if _trunc_msg else False
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
# Anthropic response.content is a list of blocks
|
||||
_text_parts = []
|
||||
@@ -7771,9 +7875,11 @@ class AIAgent:
|
||||
_trunc_content = "\n".join(_text_parts) if _text_parts else None
|
||||
|
||||
_thinking_exhausted = (
|
||||
_trunc_content is not None
|
||||
and not self._has_content_after_think_block(_trunc_content)
|
||||
) or _trunc_content is None
|
||||
not _trunc_has_tool_calls and (
|
||||
(_trunc_content is not None and not self._has_content_after_think_block(_trunc_content))
|
||||
or _trunc_content is None
|
||||
)
|
||||
)
|
||||
|
||||
if _thinking_exhausted:
|
||||
_exhaust_error = (
|
||||
@@ -7849,6 +7955,34 @@ class AIAgent:
|
||||
"error": "Response remained truncated after 3 continuation attempts",
|
||||
}
|
||||
|
||||
if self.api_mode == "chat_completions":
|
||||
assistant_message = response.choices[0].message
|
||||
if assistant_message.tool_calls:
|
||||
if truncated_tool_call_retries < 1:
|
||||
truncated_tool_call_retries += 1
|
||||
self._vprint(
|
||||
f"{self.log_prefix}⚠️ Truncated tool call detected — retrying API call...",
|
||||
force=True,
|
||||
)
|
||||
# Don't append the broken response to messages;
|
||||
# just re-run the same API call from the current
|
||||
# message state, giving the model another chance.
|
||||
continue
|
||||
self._vprint(
|
||||
f"{self.log_prefix}⚠️ Truncated tool call response detected again — refusing to execute incomplete tool arguments.",
|
||||
force=True,
|
||||
)
|
||||
self._cleanup_task_resources(effective_task_id)
|
||||
self._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"partial": True,
|
||||
"error": "Response truncated due to output length limit",
|
||||
}
|
||||
|
||||
# If we have prior messages, roll back to last complete state
|
||||
if len(messages) > 1:
|
||||
self._vprint(f"{self.log_prefix} ⏪ Rolling back to last complete assistant turn")
|
||||
@@ -8054,6 +8188,7 @@ class AIAgent:
|
||||
recovered_with_pool, has_retried_429 = self._recover_with_credential_pool(
|
||||
status_code=status_code,
|
||||
has_retried_429=has_retried_429,
|
||||
classified_reason=classified.reason,
|
||||
error_context=error_context,
|
||||
)
|
||||
if recovered_with_pool:
|
||||
@@ -8161,7 +8296,33 @@ class AIAgent:
|
||||
if _err_body_str:
|
||||
self._vprint(f"{self.log_prefix} 📋 Details: {_err_body_str}", force=True)
|
||||
self._vprint(f"{self.log_prefix} ⏱️ Elapsed: {elapsed_time:.2f}s Context: {len(api_messages)} msgs, ~{approx_tokens:,} tokens")
|
||||
|
||||
|
||||
# Actionable hint for OpenRouter "no tool endpoints" error.
|
||||
# This fires regardless of whether fallback succeeds — the
|
||||
# user needs to know WHY their model failed so they can fix
|
||||
# their provider routing, not just silently fall back.
|
||||
if (
|
||||
self._is_openrouter_url()
|
||||
and "support tool use" in error_msg
|
||||
):
|
||||
self._vprint(
|
||||
f"{self.log_prefix} 💡 No OpenRouter providers for {_model} support tool calling with your current settings.",
|
||||
force=True,
|
||||
)
|
||||
if self.providers_allowed:
|
||||
self._vprint(
|
||||
f"{self.log_prefix} Your provider_routing.only restriction is filtering out tool-capable providers.",
|
||||
force=True,
|
||||
)
|
||||
self._vprint(
|
||||
f"{self.log_prefix} Try removing the restriction or adding providers that support tools for this model.",
|
||||
force=True,
|
||||
)
|
||||
self._vprint(
|
||||
f"{self.log_prefix} Check which providers support tools: https://openrouter.ai/models/{_model}",
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Check for interrupt before deciding to retry
|
||||
if self._interrupt_requested:
|
||||
self._vprint(f"{self.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
|
||||
@@ -8217,6 +8378,10 @@ class AIAgent:
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
# Compression created a new session — clear history
|
||||
# so _flush_messages_to_session_db writes compressed
|
||||
# messages to the new session, not skipping them.
|
||||
conversation_history = None
|
||||
if len(messages) < original_len or old_ctx > _reduced_ctx:
|
||||
self._emit_status(
|
||||
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
|
||||
@@ -8274,6 +8439,10 @@ class AIAgent:
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
# Compression created a new session — clear history
|
||||
# so _flush_messages_to_session_db writes compressed
|
||||
# messages to the new session, not skipping them.
|
||||
conversation_history = None
|
||||
|
||||
if len(messages) < original_len:
|
||||
self._emit_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
|
||||
@@ -8392,6 +8561,10 @@ class AIAgent:
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
# Compression created a new session — clear history
|
||||
# so _flush_messages_to_session_db writes compressed
|
||||
# messages to the new session, not skipping them.
|
||||
conversation_history = None
|
||||
|
||||
if len(messages) < original_len or new_ctx and new_ctx < old_ctx:
|
||||
if len(messages) < original_len:
|
||||
@@ -8999,6 +9172,11 @@ class AIAgent:
|
||||
|
||||
self._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)
|
||||
|
||||
# Reset per-turn retry counters after successful tool
|
||||
# execution so a single truncation doesn't poison the
|
||||
# entire conversation.
|
||||
truncated_tool_call_retries = 0
|
||||
|
||||
# Signal that a paragraph break is needed before the next
|
||||
# streamed text. We don't emit it immediately because
|
||||
# multiple consecutive tool iterations would stack up
|
||||
|
||||
+250
-35
@@ -2,8 +2,8 @@
|
||||
# ============================================================================
|
||||
# Hermes Agent Installer
|
||||
# ============================================================================
|
||||
# Installation script for Linux and macOS.
|
||||
# Uses uv for fast Python provisioning and package management.
|
||||
# Installation script for Linux, macOS, and Android/Termux.
|
||||
# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
@@ -117,6 +117,36 @@ log_error() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
}
|
||||
|
||||
is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
get_command_link_dir() {
|
||||
if is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo "$PREFIX/bin"
|
||||
else
|
||||
echo "$HOME/.local/bin"
|
||||
fi
|
||||
}
|
||||
|
||||
get_command_link_display_dir() {
|
||||
if is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo '$PREFIX/bin'
|
||||
else
|
||||
echo '~/.local/bin'
|
||||
fi
|
||||
}
|
||||
|
||||
get_hermes_command_path() {
|
||||
local link_dir
|
||||
link_dir="$(get_command_link_dir)"
|
||||
if [ -x "$link_dir/hermes" ]; then
|
||||
echo "$link_dir/hermes"
|
||||
else
|
||||
echo "hermes"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# System detection
|
||||
# ============================================================================
|
||||
@@ -124,12 +154,17 @@ log_error() {
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux*)
|
||||
OS="linux"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
DISTRO="$ID"
|
||||
if is_termux; then
|
||||
OS="android"
|
||||
DISTRO="termux"
|
||||
else
|
||||
DISTRO="unknown"
|
||||
OS="linux"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
DISTRO="$ID"
|
||||
else
|
||||
DISTRO="unknown"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
Darwin*)
|
||||
@@ -158,6 +193,12 @@ detect_os() {
|
||||
# ============================================================================
|
||||
|
||||
install_uv() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Termux detected — using Python's stdlib venv + pip instead of uv"
|
||||
UV_CMD=""
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Checking for uv package manager..."
|
||||
|
||||
# Check common locations for uv
|
||||
@@ -209,6 +250,25 @@ install_uv() {
|
||||
}
|
||||
|
||||
check_python() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Checking Termux Python..."
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
PYTHON_PATH="$(command -v python)"
|
||||
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
log_success "Python found: $PYTHON_FOUND_VERSION"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Installing Python via pkg..."
|
||||
pkg install -y python >/dev/null
|
||||
PYTHON_PATH="$(command -v python)"
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
log_success "Python installed: $PYTHON_FOUND_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Checking Python $PYTHON_VERSION..."
|
||||
|
||||
# Let uv handle Python — it can download and manage Python versions
|
||||
@@ -243,6 +303,17 @@ check_git() {
|
||||
fi
|
||||
|
||||
log_error "Git not found"
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Installing Git via pkg..."
|
||||
pkg install -y git >/dev/null
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
GIT_VERSION=$(git --version | awk '{print $3}')
|
||||
log_success "Git $GIT_VERSION installed"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Please install Git:"
|
||||
|
||||
case "$OS" in
|
||||
@@ -262,6 +333,9 @@ check_git() {
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
android)
|
||||
log_info " pkg install git"
|
||||
;;
|
||||
macos)
|
||||
log_info " xcode-select --install"
|
||||
log_info " Or: brew install git"
|
||||
@@ -290,11 +364,29 @@ check_node() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Node.js not found — installing Node.js via pkg..."
|
||||
else
|
||||
log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
|
||||
fi
|
||||
install_node
|
||||
}
|
||||
|
||||
install_node() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Installing Node.js via pkg..."
|
||||
if pkg install -y nodejs >/dev/null; then
|
||||
local installed_ver
|
||||
installed_ver=$(node --version 2>/dev/null)
|
||||
log_success "Node.js $installed_ver installed via pkg"
|
||||
HAS_NODE=true
|
||||
else
|
||||
log_warn "Failed to install Node.js via pkg"
|
||||
HAS_NODE=false
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
local arch=$(uname -m)
|
||||
local node_arch
|
||||
case "$arch" in
|
||||
@@ -413,6 +505,30 @@ install_system_packages() {
|
||||
need_ffmpeg=true
|
||||
fi
|
||||
|
||||
# Termux always needs the Android build toolchain for the tested pip path,
|
||||
# even when ripgrep/ffmpeg are already present.
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
local termux_pkgs=(clang rust make pkg-config libffi openssl)
|
||||
if [ "$need_ripgrep" = true ]; then
|
||||
termux_pkgs+=("ripgrep")
|
||||
fi
|
||||
if [ "$need_ffmpeg" = true ]; then
|
||||
termux_pkgs+=("ffmpeg")
|
||||
fi
|
||||
|
||||
log_info "Installing Termux packages: ${termux_pkgs[*]}"
|
||||
if pkg install -y "${termux_pkgs[@]}" >/dev/null; then
|
||||
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
|
||||
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
|
||||
log_success "Termux build dependencies installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "Could not auto-install all Termux packages"
|
||||
log_info "Install manually: pkg install ${termux_pkgs[*]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Nothing to install — done
|
||||
if [ "$need_ripgrep" = false ] && [ "$need_ffmpeg" = false ]; then
|
||||
return 0
|
||||
@@ -550,6 +666,9 @@ show_manual_install_hint() {
|
||||
*) log_info " Use your package manager or visit the project homepage" ;;
|
||||
esac
|
||||
;;
|
||||
android)
|
||||
log_info " pkg install $pkg"
|
||||
;;
|
||||
macos) log_info " brew install $pkg" ;;
|
||||
esac
|
||||
}
|
||||
@@ -646,6 +765,19 @@ setup_venv() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Creating virtual environment with Termux Python..."
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
log_info "Virtual environment already exists, recreating..."
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
"$PYTHON_PATH" -m venv venv
|
||||
log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Creating virtual environment with Python $PYTHON_VERSION..."
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
@@ -662,6 +794,46 @@ setup_venv() {
|
||||
install_deps() {
|
||||
log_info "Installing dependencies..."
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
PIP_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
else
|
||||
PIP_PYTHON="$PYTHON_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "${ANDROID_API_LEVEL:-}" ]; then
|
||||
ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)"
|
||||
if [ -z "$ANDROID_API_LEVEL" ]; then
|
||||
ANDROID_API_LEVEL=24
|
||||
fi
|
||||
export ANDROID_API_LEVEL
|
||||
log_info "Using ANDROID_API_LEVEL=$ANDROID_API_LEVEL for Android wheel builds"
|
||||
fi
|
||||
|
||||
"$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
|
||||
log_warn "Termux feature install (.[termux]) failed, trying base install..."
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
|
||||
log_error "Package installation failed on Termux."
|
||||
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Main package installed"
|
||||
log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps."
|
||||
|
||||
if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
|
||||
log_info "tinker-atropos submodule found — skipping install (optional, for RL training)"
|
||||
log_info " To install later: $PIP_PYTHON -m pip install -e \"./tinker-atropos\""
|
||||
fi
|
||||
|
||||
log_success "All dependencies installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
# Tell uv to install into our venv (no need to activate)
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
@@ -743,19 +915,35 @@ setup_path() {
|
||||
if [ ! -x "$HERMES_BIN" ]; then
|
||||
log_warn "hermes entry point not found at $HERMES_BIN"
|
||||
log_info "This usually means the pip install didn't complete successfully."
|
||||
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
|
||||
else
|
||||
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Create symlink in ~/.local/bin (standard user binary location, usually on PATH)
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes"
|
||||
log_success "Symlinked hermes → ~/.local/bin/hermes"
|
||||
local command_link_dir
|
||||
local command_link_display_dir
|
||||
command_link_dir="$(get_command_link_dir)"
|
||||
command_link_display_dir="$(get_command_link_display_dir)"
|
||||
|
||||
# Create a user-facing shim for the hermes command.
|
||||
mkdir -p "$command_link_dir"
|
||||
ln -sf "$HERMES_BIN" "$command_link_dir/hermes"
|
||||
log_success "Symlinked hermes → $command_link_display_dir/hermes"
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
export PATH="$command_link_dir:$PATH"
|
||||
log_info "$command_link_display_dir is the native Termux command path"
|
||||
log_success "hermes command ready"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check if ~/.local/bin is on PATH; if not, add it to shell config.
|
||||
# Detect the user's actual login shell (not the shell running this script,
|
||||
# which is always bash when piped from curl).
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$command_link_dir$"; then
|
||||
SHELL_CONFIGS=()
|
||||
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
|
||||
case "$LOGIN_SHELL" in
|
||||
@@ -801,7 +989,7 @@ setup_path() {
|
||||
fi
|
||||
|
||||
# Export for current session so hermes works immediately
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export PATH="$command_link_dir:$PATH"
|
||||
|
||||
log_success "hermes command ready"
|
||||
}
|
||||
@@ -878,6 +1066,13 @@ install_node_deps() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Skipping automatic Node/browser dependency setup on Termux"
|
||||
log_info "Browser automation and WhatsApp bridge are not part of the tested Termux install path yet."
|
||||
log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
log_info "Installing Node.js dependencies (browser tools)..."
|
||||
cd "$INSTALL_DIR"
|
||||
@@ -992,8 +1187,7 @@ maybe_start_gateway() {
|
||||
read -p "Pair WhatsApp now? [Y/n] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||
HERMES_CMD="$HOME/.local/bin/hermes"
|
||||
[ ! -x "$HERMES_CMD" ] && HERMES_CMD="hermes"
|
||||
HERMES_CMD="$(get_hermes_command_path)"
|
||||
$HERMES_CMD whatsapp || true
|
||||
fi
|
||||
else
|
||||
@@ -1007,16 +1201,17 @@ maybe_start_gateway() {
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r < /dev/tty
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
read -p "Would you like to start the gateway in the background? [Y/n] " -n 1 -r < /dev/tty
|
||||
else
|
||||
read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r < /dev/tty
|
||||
fi
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||
HERMES_CMD="$HOME/.local/bin/hermes"
|
||||
if [ ! -x "$HERMES_CMD" ]; then
|
||||
HERMES_CMD="hermes"
|
||||
fi
|
||||
HERMES_CMD="$(get_hermes_command_path)"
|
||||
|
||||
if command -v systemctl &> /dev/null; then
|
||||
if [ "$DISTRO" != "termux" ] && command -v systemctl &> /dev/null; then
|
||||
log_info "Installing systemd service..."
|
||||
if $HERMES_CMD gateway install 2>/dev/null; then
|
||||
log_success "Gateway service installed"
|
||||
@@ -1029,12 +1224,19 @@ maybe_start_gateway() {
|
||||
log_warn "Systemd install failed. You can start manually: hermes gateway"
|
||||
fi
|
||||
else
|
||||
log_info "systemd not available — starting gateway in background..."
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Termux detected — starting gateway in best-effort background mode..."
|
||||
else
|
||||
log_info "systemd not available — starting gateway in background..."
|
||||
fi
|
||||
nohup $HERMES_CMD gateway > "$HERMES_HOME/logs/gateway.log" 2>&1 &
|
||||
GATEWAY_PID=$!
|
||||
log_success "Gateway started (PID $GATEWAY_PID). Logs: ~/.hermes/logs/gateway.log"
|
||||
log_info "To stop: kill $GATEWAY_PID"
|
||||
log_info "To restart later: hermes gateway"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_warn "Android may stop background processes when Termux is suspended or the system reclaims resources."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log_info "Skipped. Start the gateway later with: hermes gateway"
|
||||
@@ -1073,24 +1275,33 @@ print_success() {
|
||||
|
||||
echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}"
|
||||
echo ""
|
||||
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
|
||||
if [ "$LOGIN_SHELL" = "zsh" ]; then
|
||||
echo " source ~/.zshrc"
|
||||
elif [ "$LOGIN_SHELL" = "bash" ]; then
|
||||
echo " source ~/.bashrc"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
echo -e "${YELLOW}⚡ 'hermes' was linked into $(get_command_link_display_dir), which is already on PATH in Termux.${NC}"
|
||||
echo ""
|
||||
else
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}"
|
||||
echo ""
|
||||
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
|
||||
if [ "$LOGIN_SHELL" = "zsh" ]; then
|
||||
echo " source ~/.zshrc"
|
||||
elif [ "$LOGIN_SHELL" = "bash" ]; then
|
||||
echo " source ~/.bashrc"
|
||||
else
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Show Node.js warning if auto-install failed
|
||||
if [ "$HAS_NODE" = false ]; then
|
||||
echo -e "${YELLOW}"
|
||||
echo "Note: Node.js could not be installed automatically."
|
||||
echo "Browser tools need Node.js. Install manually:"
|
||||
echo " https://nodejs.org/en/download/"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
echo " pkg install nodejs"
|
||||
else
|
||||
echo " https://nodejs.org/en/download/"
|
||||
fi
|
||||
echo -e "${NC}"
|
||||
fi
|
||||
|
||||
@@ -1099,7 +1310,11 @@ print_success() {
|
||||
echo -e "${YELLOW}"
|
||||
echo "Note: ripgrep (rg) was not found. File search will use"
|
||||
echo "grep as a fallback. For faster search in large codebases,"
|
||||
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
echo "install ripgrep: pkg install ripgrep"
|
||||
else
|
||||
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
|
||||
fi
|
||||
echo -e "${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
+212
-120
@@ -3,17 +3,17 @@
|
||||
# Hermes Agent Setup Script
|
||||
# ============================================================================
|
||||
# Quick setup for developers who cloned the repo manually.
|
||||
# Uses uv for fast Python provisioning and package management.
|
||||
# Uses uv for desktop/server setup and Python's stdlib venv + pip on Termux.
|
||||
#
|
||||
# Usage:
|
||||
# ./setup-hermes.sh
|
||||
#
|
||||
# This script:
|
||||
# 1. Installs uv if not present
|
||||
# 2. Creates a virtual environment with Python 3.11 via uv
|
||||
# 3. Installs all dependencies (main package + submodules)
|
||||
# 1. Detects desktop/server vs Android/Termux setup path
|
||||
# 2. Creates a Python 3.11 virtual environment
|
||||
# 3. Installs the appropriate dependency set for the platform
|
||||
# 4. Creates .env from template (if not exists)
|
||||
# 5. Symlinks the 'hermes' CLI command into ~/.local/bin
|
||||
# 5. Symlinks the 'hermes' CLI command into a user-facing bin dir
|
||||
# 6. Runs the setup wizard (optional)
|
||||
# ============================================================================
|
||||
|
||||
@@ -31,6 +31,26 @@ cd "$SCRIPT_DIR"
|
||||
|
||||
PYTHON_VERSION="3.11"
|
||||
|
||||
is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
get_command_link_dir() {
|
||||
if is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo "$PREFIX/bin"
|
||||
else
|
||||
echo "$HOME/.local/bin"
|
||||
fi
|
||||
}
|
||||
|
||||
get_command_link_display_dir() {
|
||||
if is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo '$PREFIX/bin'
|
||||
else
|
||||
echo '~/.local/bin'
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}⚕ Hermes Agent Setup${NC}"
|
||||
echo ""
|
||||
@@ -42,36 +62,40 @@ echo ""
|
||||
echo -e "${CYAN}→${NC} Checking for uv..."
|
||||
|
||||
UV_CMD=""
|
||||
if command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
elif [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
fi
|
||||
|
||||
if [ -n "$UV_CMD" ]; then
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} uv found ($UV_VERSION)"
|
||||
if is_termux; then
|
||||
echo -e "${CYAN}→${NC} Termux detected — using Python's stdlib venv + pip instead of uv"
|
||||
else
|
||||
echo -e "${CYAN}→${NC} Installing uv..."
|
||||
if curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null; then
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
fi
|
||||
|
||||
if [ -n "$UV_CMD" ]; then
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} uv installed ($UV_VERSION)"
|
||||
if command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
elif [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
fi
|
||||
|
||||
if [ -n "$UV_CMD" ]; then
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} uv found ($UV_VERSION)"
|
||||
else
|
||||
echo -e "${CYAN}→${NC} Installing uv..."
|
||||
if curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null; then
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
fi
|
||||
|
||||
if [ -n "$UV_CMD" ]; then
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} uv installed ($UV_VERSION)"
|
||||
else
|
||||
echo -e "${RED}✗${NC} uv installed but not found. Add ~/.local/bin to PATH and retry."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗${NC} uv installed but not found. Add ~/.local/bin to PATH and retry."
|
||||
echo -e "${RED}✗${NC} Failed to install uv. Visit https://docs.astral.sh/uv/"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to install uv. Visit https://docs.astral.sh/uv/"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -81,16 +105,34 @@ fi
|
||||
|
||||
echo -e "${CYAN}→${NC} Checking Python $PYTHON_VERSION..."
|
||||
|
||||
if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then
|
||||
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found"
|
||||
if is_termux; then
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
PYTHON_PATH="$(command -v python)"
|
||||
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found"
|
||||
else
|
||||
echo -e "${RED}✗${NC} Termux Python must be 3.11+"
|
||||
echo " Run: pkg install python"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗${NC} Python not found in Termux"
|
||||
echo " Run: pkg install python"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${CYAN}→${NC} Python $PYTHON_VERSION not found, installing via uv..."
|
||||
$UV_CMD python install "$PYTHON_VERSION"
|
||||
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION installed"
|
||||
if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then
|
||||
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found"
|
||||
else
|
||||
echo -e "${CYAN}→${NC} Python $PYTHON_VERSION not found, installing via uv..."
|
||||
$UV_CMD python install "$PYTHON_VERSION"
|
||||
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
@@ -104,11 +146,16 @@ if [ -d "venv" ]; then
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
$UV_CMD venv venv --python "$PYTHON_VERSION"
|
||||
echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)"
|
||||
if is_termux; then
|
||||
"$PYTHON_PATH" -m venv venv
|
||||
echo -e "${GREEN}✓${NC} venv created with stdlib venv"
|
||||
else
|
||||
$UV_CMD venv venv --python "$PYTHON_VERSION"
|
||||
echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)"
|
||||
fi
|
||||
|
||||
# Tell uv to install into this venv (no activation needed for uv)
|
||||
export VIRTUAL_ENV="$SCRIPT_DIR/venv"
|
||||
SETUP_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
|
||||
# ============================================================================
|
||||
# Dependencies
|
||||
@@ -116,19 +163,34 @@ export VIRTUAL_ENV="$SCRIPT_DIR/venv"
|
||||
|
||||
echo -e "${CYAN}→${NC} Installing dependencies..."
|
||||
|
||||
# Prefer uv sync with lockfile (hash-verified installs) when available,
|
||||
# fall back to pip install for compatibility or when lockfile is stale.
|
||||
if [ -f "uv.lock" ]; then
|
||||
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..."
|
||||
if is_termux; then
|
||||
export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || printf '%s' "${ANDROID_API_LEVEL:-}")"
|
||||
echo -e "${CYAN}→${NC} Termux detected — installing the tested Android bundle"
|
||||
"$SETUP_PYTHON" -m pip install --upgrade pip setuptools wheel
|
||||
if [ -f "constraints-termux.txt" ]; then
|
||||
"$SETUP_PYTHON" -m pip install -e ".[termux]" -c constraints-termux.txt || {
|
||||
echo -e "${YELLOW}⚠${NC} Termux bundle install failed, falling back to base install..."
|
||||
"$SETUP_PYTHON" -m pip install -e "." -c constraints-termux.txt
|
||||
}
|
||||
else
|
||||
"$SETUP_PYTHON" -m pip install -e ".[termux]" || "$SETUP_PYTHON" -m pip install -e "."
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
else
|
||||
# Prefer uv sync with lockfile (hash-verified installs) when available,
|
||||
# fall back to pip install for compatibility or when lockfile is stale.
|
||||
if [ -f "uv.lock" ]; then
|
||||
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"
|
||||
}
|
||||
else
|
||||
$UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "."
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
}
|
||||
else
|
||||
$UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "."
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
@@ -138,7 +200,9 @@ fi
|
||||
echo -e "${CYAN}→${NC} Installing optional submodules..."
|
||||
|
||||
# tinker-atropos (RL training backend)
|
||||
if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
|
||||
if is_termux; then
|
||||
echo -e "${CYAN}→${NC} Skipping tinker-atropos on Termux (not part of the tested Android path)"
|
||||
elif [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
|
||||
$UV_CMD pip install -e "./tinker-atropos" && \
|
||||
echo -e "${GREEN}✓${NC} tinker-atropos installed" || \
|
||||
echo -e "${YELLOW}⚠${NC} tinker-atropos install failed (RL tools may not work)"
|
||||
@@ -160,34 +224,42 @@ else
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||
INSTALLED=false
|
||||
|
||||
# Check if sudo is available
|
||||
if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
|
||||
if command -v apt &> /dev/null; then
|
||||
sudo apt install -y ripgrep && INSTALLED=true
|
||||
elif command -v dnf &> /dev/null; then
|
||||
sudo dnf install -y ripgrep && INSTALLED=true
|
||||
|
||||
if is_termux; then
|
||||
pkg install -y ripgrep && INSTALLED=true
|
||||
else
|
||||
# Check if sudo is available
|
||||
if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
|
||||
if command -v apt &> /dev/null; then
|
||||
sudo apt install -y ripgrep && INSTALLED=true
|
||||
elif command -v dnf &> /dev/null; then
|
||||
sudo dnf install -y ripgrep && INSTALLED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try brew (no sudo needed)
|
||||
if [ "$INSTALLED" = false ] && command -v brew &> /dev/null; then
|
||||
brew install ripgrep && INSTALLED=true
|
||||
fi
|
||||
|
||||
# Try cargo (no sudo needed)
|
||||
if [ "$INSTALLED" = false ] && command -v cargo &> /dev/null; then
|
||||
echo -e "${CYAN}→${NC} Trying cargo install (no sudo required)..."
|
||||
cargo install ripgrep && INSTALLED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try brew (no sudo needed)
|
||||
if [ "$INSTALLED" = false ] && command -v brew &> /dev/null; then
|
||||
brew install ripgrep && INSTALLED=true
|
||||
fi
|
||||
|
||||
# Try cargo (no sudo needed)
|
||||
if [ "$INSTALLED" = false ] && command -v cargo &> /dev/null; then
|
||||
echo -e "${CYAN}→${NC} Trying cargo install (no sudo required)..."
|
||||
cargo install ripgrep && INSTALLED=true
|
||||
fi
|
||||
|
||||
|
||||
if [ "$INSTALLED" = true ]; then
|
||||
echo -e "${GREEN}✓${NC} ripgrep installed"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Auto-install failed. Install options:"
|
||||
echo " sudo apt install ripgrep # Debian/Ubuntu"
|
||||
echo " brew install ripgrep # macOS"
|
||||
echo " cargo install ripgrep # With Rust (no sudo)"
|
||||
if is_termux; then
|
||||
echo " pkg install ripgrep # Termux / Android"
|
||||
else
|
||||
echo " sudo apt install ripgrep # Debian/Ubuntu"
|
||||
echo " brew install ripgrep # macOS"
|
||||
echo " cargo install ripgrep # With Rust (no sudo)"
|
||||
fi
|
||||
echo " https://github.com/BurntSushi/ripgrep#installation"
|
||||
fi
|
||||
fi
|
||||
@@ -207,49 +279,56 @@ else
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# PATH setup — symlink hermes into ~/.local/bin
|
||||
# PATH setup — symlink hermes into a user-facing bin dir
|
||||
# ============================================================================
|
||||
|
||||
echo -e "${CYAN}→${NC} Setting up hermes command..."
|
||||
|
||||
HERMES_BIN="$SCRIPT_DIR/venv/bin/hermes"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes"
|
||||
echo -e "${GREEN}✓${NC} Symlinked hermes → ~/.local/bin/hermes"
|
||||
COMMAND_LINK_DIR="$(get_command_link_dir)"
|
||||
COMMAND_LINK_DISPLAY_DIR="$(get_command_link_display_dir)"
|
||||
mkdir -p "$COMMAND_LINK_DIR"
|
||||
ln -sf "$HERMES_BIN" "$COMMAND_LINK_DIR/hermes"
|
||||
echo -e "${GREEN}✓${NC} Symlinked hermes → $COMMAND_LINK_DISPLAY_DIR/hermes"
|
||||
|
||||
# Determine the appropriate shell config file
|
||||
SHELL_CONFIG=""
|
||||
if [[ "$SHELL" == *"zsh"* ]]; then
|
||||
SHELL_CONFIG="$HOME/.zshrc"
|
||||
elif [[ "$SHELL" == *"bash"* ]]; then
|
||||
SHELL_CONFIG="$HOME/.bashrc"
|
||||
[ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile"
|
||||
if is_termux; then
|
||||
export PATH="$COMMAND_LINK_DIR:$PATH"
|
||||
echo -e "${GREEN}✓${NC} $COMMAND_LINK_DISPLAY_DIR is already on PATH in Termux"
|
||||
else
|
||||
# Fallback to checking existing files
|
||||
if [ -f "$HOME/.zshrc" ]; then
|
||||
# Determine the appropriate shell config file
|
||||
SHELL_CONFIG=""
|
||||
if [[ "$SHELL" == *"zsh"* ]]; then
|
||||
SHELL_CONFIG="$HOME/.zshrc"
|
||||
elif [ -f "$HOME/.bashrc" ]; then
|
||||
elif [[ "$SHELL" == *"bash"* ]]; then
|
||||
SHELL_CONFIG="$HOME/.bashrc"
|
||||
elif [ -f "$HOME/.bash_profile" ]; then
|
||||
SHELL_CONFIG="$HOME/.bash_profile"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$SHELL_CONFIG" ]; then
|
||||
# Touch the file just in case it doesn't exist yet but was selected
|
||||
touch "$SHELL_CONFIG" 2>/dev/null || true
|
||||
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then
|
||||
if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then
|
||||
echo "" >> "$SHELL_CONFIG"
|
||||
echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG"
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG"
|
||||
echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG"
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG"
|
||||
fi
|
||||
[ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile"
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH"
|
||||
# Fallback to checking existing files
|
||||
if [ -f "$HOME/.zshrc" ]; then
|
||||
SHELL_CONFIG="$HOME/.zshrc"
|
||||
elif [ -f "$HOME/.bashrc" ]; then
|
||||
SHELL_CONFIG="$HOME/.bashrc"
|
||||
elif [ -f "$HOME/.bash_profile" ]; then
|
||||
SHELL_CONFIG="$HOME/.bash_profile"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$SHELL_CONFIG" ]; then
|
||||
# Touch the file just in case it doesn't exist yet but was selected
|
||||
touch "$SHELL_CONFIG" 2>/dev/null || true
|
||||
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then
|
||||
if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then
|
||||
echo "" >> "$SHELL_CONFIG"
|
||||
echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG"
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG"
|
||||
echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG"
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG"
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -281,18 +360,31 @@ echo -e "${GREEN}✓ Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo ""
|
||||
echo " 1. Reload your shell:"
|
||||
echo " source $SHELL_CONFIG"
|
||||
echo ""
|
||||
echo " 2. Run the setup wizard to configure API keys:"
|
||||
echo " hermes setup"
|
||||
echo ""
|
||||
echo " 3. Start chatting:"
|
||||
echo " hermes"
|
||||
echo ""
|
||||
if is_termux; then
|
||||
echo " 1. Run the setup wizard to configure API keys:"
|
||||
echo " hermes setup"
|
||||
echo ""
|
||||
echo " 2. Start chatting:"
|
||||
echo " hermes"
|
||||
echo ""
|
||||
else
|
||||
echo " 1. Reload your shell:"
|
||||
echo " source $SHELL_CONFIG"
|
||||
echo ""
|
||||
echo " 2. Run the setup wizard to configure API keys:"
|
||||
echo " hermes setup"
|
||||
echo ""
|
||||
echo " 3. Start chatting:"
|
||||
echo " hermes"
|
||||
echo ""
|
||||
fi
|
||||
echo "Other commands:"
|
||||
echo " hermes status # Check configuration"
|
||||
echo " hermes gateway install # Install gateway service (messaging + cron)"
|
||||
if is_termux; then
|
||||
echo " hermes gateway # Run gateway in foreground"
|
||||
else
|
||||
echo " hermes gateway install # Install gateway service (messaging + cron)"
|
||||
fi
|
||||
echo " hermes cron list # View scheduled jobs"
|
||||
echo " hermes doctor # Diagnose issues"
|
||||
echo ""
|
||||
|
||||
@@ -68,9 +68,22 @@ class TestInitialize:
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
caps = resp.agent_capabilities
|
||||
assert isinstance(caps, AgentCapabilities)
|
||||
assert caps.load_session is True
|
||||
assert caps.session_capabilities is not None
|
||||
assert caps.session_capabilities.fork is not None
|
||||
assert caps.session_capabilities.list is not None
|
||||
assert caps.session_capabilities.resume is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_capabilities_wire_format(self, agent):
|
||||
"""Verify the JSON wire format uses correct aliases so ACP clients see the right keys."""
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
payload = resp.agent_capabilities.model_dump(by_alias=True, exclude_none=True)
|
||||
assert payload["loadSession"] is True
|
||||
session_caps = payload["sessionCapabilities"]
|
||||
assert "fork" in session_caps
|
||||
assert "list" in session_caps
|
||||
assert "resume" in session_caps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -410,6 +423,37 @@ class TestPrompt:
|
||||
update = last_call[1].get("update") or last_call[0][1]
|
||||
assert update.session_update == "agent_message_chunk"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_populates_usage_from_top_level_run_conversation_fields(self, agent):
|
||||
"""ACP should map top-level token fields into PromptResponse.usage."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
state.agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "usage attached",
|
||||
"messages": [],
|
||||
"prompt_tokens": 123,
|
||||
"completion_tokens": 45,
|
||||
"total_tokens": 168,
|
||||
"reasoning_tokens": 7,
|
||||
"cache_read_tokens": 11,
|
||||
})
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="show usage")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
assert isinstance(resp, PromptResponse)
|
||||
assert resp.usage is not None
|
||||
assert resp.usage.input_tokens == 123
|
||||
assert resp.usage.output_tokens == 45
|
||||
assert resp.usage.total_tokens == 168
|
||||
assert resp.usage.thought_tokens == 7
|
||||
assert resp.usage.cached_read_tokens == 11
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_cancelled_returns_cancelled_stop_reason(self, agent):
|
||||
"""If cancel is called during prompt, stop_reason should be 'cancelled'."""
|
||||
|
||||
@@ -80,6 +80,9 @@ class TestBuildAnthropicClient:
|
||||
build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com")
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["base_url"] == "https://custom.api.com"
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
}
|
||||
|
||||
def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
@@ -91,7 +94,20 @@ class TestBuildAnthropicClient:
|
||||
assert kwargs["auth_token"] == "minimax-secret-123"
|
||||
assert "api_key" not in kwargs
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14"
|
||||
}
|
||||
|
||||
def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self):
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(
|
||||
"minimax-cn-secret-123",
|
||||
base_url="https://api.minimaxi.com/anthropic",
|
||||
)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
assert kwargs["auth_token"] == "minimax-cn-secret-123"
|
||||
assert "api_key" not in kwargs
|
||||
assert kwargs["default_headers"] == {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1111,3 +1111,45 @@ class TestCallLlmPaymentFallback:
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate: _resolve_api_key_provider must skip anthropic when not configured
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
|
||||
"""_resolve_api_key_provider must not try anthropic when user never configured it."""
|
||||
from collections import OrderedDict
|
||||
from hermes_cli.auth import ProviderConfig
|
||||
|
||||
# Build a minimal registry with only "anthropic" so the loop is guaranteed
|
||||
# to reach it without being short-circuited by earlier providers.
|
||||
fake_registry = OrderedDict({
|
||||
"anthropic": ProviderConfig(
|
||||
id="anthropic",
|
||||
name="Anthropic",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://api.anthropic.com",
|
||||
api_key_env_vars=("ANTHROPIC_API_KEY",),
|
||||
),
|
||||
})
|
||||
|
||||
called = []
|
||||
|
||||
def mock_try_anthropic():
|
||||
called.append("anthropic")
|
||||
return None, None
|
||||
|
||||
monkeypatch.setattr("agent.auxiliary_client._try_anthropic", mock_try_anthropic)
|
||||
monkeypatch.setattr("hermes_cli.auth.PROVIDER_REGISTRY", fake_registry)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.is_provider_explicitly_configured",
|
||||
lambda pid: False,
|
||||
)
|
||||
|
||||
from agent.auxiliary_client import _resolve_api_key_provider
|
||||
_resolve_api_key_provider()
|
||||
|
||||
assert "anthropic" not in called, \
|
||||
"_try_anthropic() should not be called when anthropic is not explicitly configured"
|
||||
|
||||
@@ -567,6 +567,7 @@ def test_singleton_seed_does_not_clobber_manual_oauth_entry(tmp_path, monkeypatc
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
@@ -702,53 +703,6 @@ def test_least_used_strategy_selects_lowest_count(tmp_path, monkeypatch):
|
||||
assert entry.access_token == "sk-or-light"
|
||||
|
||||
|
||||
def test_mark_used_increments_request_count(tmp_path, monkeypatch):
|
||||
"""mark_used should increment the request_count of the current entry."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool.get_pool_strategy",
|
||||
lambda _provider: "fill_first",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_env",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "key-a",
|
||||
"label": "test",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-or-test",
|
||||
"request_count": 5,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
assert entry is not None
|
||||
assert entry.request_count == 5
|
||||
pool.mark_used()
|
||||
updated = pool.current()
|
||||
assert updated is not None
|
||||
assert updated.request_count == 6
|
||||
|
||||
|
||||
def test_thread_safety_concurrent_select(tmp_path, monkeypatch):
|
||||
"""Concurrent select() calls should not corrupt pool state."""
|
||||
import threading as _threading
|
||||
@@ -798,7 +752,6 @@ def test_thread_safety_concurrent_select(tmp_path, monkeypatch):
|
||||
entry = pool.select()
|
||||
if entry:
|
||||
results.append(entry.id)
|
||||
pool.mark_used(entry.id)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
@@ -1056,8 +1009,8 @@ def test_acquire_lease_prefers_unleased_entry(tmp_path, monkeypatch):
|
||||
|
||||
assert first == "cred-1"
|
||||
assert second == "cred-2"
|
||||
assert pool.active_lease_count("cred-1") == 1
|
||||
assert pool.active_lease_count("cred-2") == 1
|
||||
assert pool._active_leases.get("cred-1", 0) == 1
|
||||
assert pool._active_leases.get("cred-2", 0) == 1
|
||||
|
||||
|
||||
|
||||
@@ -1087,7 +1040,34 @@ def test_release_lease_decrements_counter(tmp_path, monkeypatch):
|
||||
pool = load_pool("openrouter")
|
||||
leased = pool.acquire_lease()
|
||||
assert leased == "cred-1"
|
||||
assert pool.active_lease_count("cred-1") == 1
|
||||
assert pool._active_leases.get("cred-1", 0) == 1
|
||||
|
||||
pool.release_lease("cred-1")
|
||||
assert pool.active_lease_count("cred-1") == 0
|
||||
assert pool._active_leases.get("cred-1", 0) == 0
|
||||
|
||||
|
||||
def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_path, monkeypatch):
|
||||
"""Claude Code credentials must not be auto-seeded when the user never selected anthropic."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
|
||||
|
||||
# Claude Code credentials exist on disk
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: {"accessToken": "sk-ant...oken", "refreshToken": "rt", "expiresAt": 9999999999999},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_hermes_oauth_credentials",
|
||||
lambda: None,
|
||||
)
|
||||
# User configured kimi-coding, NOT anthropic
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.is_provider_explicitly_configured",
|
||||
lambda pid: pid == "kimi-coding",
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool("anthropic")
|
||||
|
||||
# Should NOT have seeded the claude_code entry
|
||||
assert pool.entries() == []
|
||||
|
||||
@@ -75,28 +75,6 @@ class TestClassifiedError:
|
||||
e3 = ClassifiedError(reason=FailoverReason.billing)
|
||||
assert e3.is_auth is False
|
||||
|
||||
def test_is_transient_property(self):
|
||||
transient_reasons = [
|
||||
FailoverReason.rate_limit,
|
||||
FailoverReason.overloaded,
|
||||
FailoverReason.server_error,
|
||||
FailoverReason.timeout,
|
||||
FailoverReason.unknown,
|
||||
]
|
||||
for reason in transient_reasons:
|
||||
e = ClassifiedError(reason=reason)
|
||||
assert e.is_transient is True, f"{reason} should be transient"
|
||||
|
||||
non_transient = [
|
||||
FailoverReason.auth,
|
||||
FailoverReason.billing,
|
||||
FailoverReason.model_not_found,
|
||||
FailoverReason.format_error,
|
||||
]
|
||||
for reason in non_transient:
|
||||
e = ClassifiedError(reason=reason)
|
||||
assert e.is_transient is False, f"{reason} should NOT be transient"
|
||||
|
||||
def test_defaults(self):
|
||||
e = ClassifiedError(reason=FailoverReason.unknown)
|
||||
assert e.retryable is True
|
||||
@@ -480,6 +458,39 @@ class TestClassifyApiError:
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
|
||||
# ── Message-only usage limit disambiguation (no status code) ──
|
||||
|
||||
def test_message_usage_limit_transient_is_rate_limit(self):
|
||||
"""'usage limit' + 'try again' with no status code → rate_limit, not billing."""
|
||||
e = Exception("usage limit exceeded, try again in 5 minutes")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
assert result.should_rotate_credential is True
|
||||
assert result.should_fallback is True
|
||||
|
||||
def test_message_usage_limit_no_retry_signal_is_billing(self):
|
||||
"""'usage limit' with no transient signal and no status code → billing."""
|
||||
e = Exception("usage limit reached")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.billing
|
||||
assert result.retryable is False
|
||||
assert result.should_rotate_credential is True
|
||||
|
||||
def test_message_quota_with_reset_window_is_rate_limit(self):
|
||||
"""'quota' + 'resets at' with no status code → rate_limit."""
|
||||
e = Exception("quota exceeded, resets at midnight UTC")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
|
||||
def test_message_limit_exceeded_with_wait_is_rate_limit(self):
|
||||
"""'limit exceeded' + 'wait' with no status code → rate_limit."""
|
||||
e = Exception("key limit exceeded, please wait before retrying")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.rate_limit
|
||||
assert result.retryable is True
|
||||
|
||||
# ── Unknown / fallback ──
|
||||
|
||||
def test_generic_exception_is_unknown(self):
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for local provider stream read timeout auto-detection.
|
||||
|
||||
When a local LLM provider is detected (Ollama, llama.cpp, vLLM, etc.),
|
||||
the httpx stream read timeout should be automatically increased from the
|
||||
default 60s to HERMES_API_TIMEOUT (1800s) to avoid premature connection
|
||||
kills during long prefill phases.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
|
||||
|
||||
class TestLocalStreamReadTimeout:
|
||||
"""Verify stream read timeout auto-detection logic."""
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"http://localhost:11434",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://0.0.0.0:5000",
|
||||
"http://192.168.1.100:8000",
|
||||
"http://10.0.0.5:1234",
|
||||
])
|
||||
def test_local_endpoint_bumps_read_timeout(self, base_url):
|
||||
"""Local endpoint + default timeout -> bumps to base_timeout."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_STREAM_READ_TIMEOUT", None)
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 1800.0
|
||||
|
||||
def test_user_override_respected_for_local(self):
|
||||
"""User sets HERMES_STREAM_READ_TIMEOUT -> keep their value even for local."""
|
||||
with patch.dict(os.environ, {"HERMES_STREAM_READ_TIMEOUT": "300"}, clear=False):
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
base_url = "http://localhost:11434"
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 300.0
|
||||
|
||||
@pytest.mark.parametrize("base_url", [
|
||||
"https://api.openai.com",
|
||||
"https://openrouter.ai/api",
|
||||
"https://api.anthropic.com",
|
||||
])
|
||||
def test_remote_endpoint_keeps_default(self, base_url):
|
||||
"""Remote endpoint -> keep 120s default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_STREAM_READ_TIMEOUT", None)
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 120.0
|
||||
|
||||
def test_empty_base_url_keeps_default(self):
|
||||
"""No base_url set -> keep 120s default."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_STREAM_READ_TIMEOUT", None)
|
||||
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
base_url = ""
|
||||
if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
|
||||
_stream_read_timeout = _base_timeout
|
||||
assert _stream_read_timeout == 120.0
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog."""
|
||||
"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog, beta headers."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMinimaxContextLengths:
|
||||
@@ -103,3 +105,100 @@ class TestMinimaxModelCatalog:
|
||||
models = _PROVIDER_MODELS[provider]
|
||||
assert "MiniMax-M2.7-highspeed" not in models
|
||||
assert "MiniMax-M2.5-highspeed" not in models
|
||||
|
||||
|
||||
class TestMinimaxBetaHeaders:
|
||||
"""MiniMax Anthropic-compat endpoints reject fine-grained-tool-streaming beta.
|
||||
|
||||
Verify that build_anthropic_client omits the tool-streaming beta for MiniMax
|
||||
(both global and China domains) while keeping it for native Anthropic and
|
||||
other third-party endpoints. Covers the fix for #6510 / #6555.
|
||||
"""
|
||||
|
||||
_TOOL_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||||
_THINKING_BETA = "interleaved-thinking-2025-05-14"
|
||||
|
||||
# -- helper ----------------------------------------------------------
|
||||
|
||||
def _build_and_get_betas(self, api_key, base_url=None):
|
||||
"""Build client, return the anthropic-beta header string."""
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
|
||||
build_anthropic_client(api_key, base_url=base_url)
|
||||
kwargs = mock_sdk.Anthropic.call_args[1]
|
||||
headers = kwargs.get("default_headers", {})
|
||||
return headers.get("anthropic-beta", "")
|
||||
|
||||
# -- MiniMax global --------------------------------------------------
|
||||
|
||||
def test_minimax_global_omits_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-key-123", base_url="https://api.minimax.io/anthropic"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
assert self._THINKING_BETA in betas
|
||||
|
||||
def test_minimax_global_trailing_slash(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-key-123", base_url="https://api.minimax.io/anthropic/"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
|
||||
# -- MiniMax China ---------------------------------------------------
|
||||
|
||||
def test_minimax_cn_omits_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
assert self._THINKING_BETA in betas
|
||||
|
||||
def test_minimax_cn_trailing_slash(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"mm-cn-key-456", base_url="https://api.minimaxi.com/anthropic/"
|
||||
)
|
||||
assert self._TOOL_BETA not in betas
|
||||
|
||||
# -- Non-MiniMax keeps full betas ------------------------------------
|
||||
|
||||
def test_native_anthropic_keeps_tool_streaming(self):
|
||||
betas = self._build_and_get_betas("sk-ant-api03-real-key-here")
|
||||
assert self._TOOL_BETA in betas
|
||||
assert self._THINKING_BETA in betas
|
||||
|
||||
def test_third_party_proxy_keeps_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"custom-key", base_url="https://my-proxy.example.com/anthropic"
|
||||
)
|
||||
assert self._TOOL_BETA in betas
|
||||
|
||||
def test_custom_base_url_keeps_tool_streaming(self):
|
||||
betas = self._build_and_get_betas(
|
||||
"custom-key", base_url="https://custom.api.com"
|
||||
)
|
||||
assert self._TOOL_BETA in betas
|
||||
|
||||
# -- _common_betas_for_base_url unit tests ---------------------------
|
||||
|
||||
def test_common_betas_none_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
|
||||
assert _common_betas_for_base_url(None) == _COMMON_BETAS
|
||||
|
||||
def test_common_betas_empty_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
|
||||
assert _common_betas_for_base_url("") == _COMMON_BETAS
|
||||
|
||||
def test_common_betas_minimax_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
|
||||
betas = _common_betas_for_base_url("https://api.minimax.io/anthropic")
|
||||
assert _TOOL_STREAMING_BETA not in betas
|
||||
assert len(betas) > 0 # still has other betas
|
||||
|
||||
def test_common_betas_minimax_cn_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA
|
||||
betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic")
|
||||
assert _TOOL_STREAMING_BETA not in betas
|
||||
|
||||
def test_common_betas_regular_url(self):
|
||||
from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS
|
||||
assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
|
||||
|
||||
@@ -132,6 +132,61 @@ class TestDefaultContextLengths:
|
||||
if "gemini" in key:
|
||||
assert value == 1048576, f"{key} should be 1048576"
|
||||
|
||||
def test_grok_models_context_lengths(self):
|
||||
# xAI /v1/models does not return context_length metadata, so
|
||||
# DEFAULT_CONTEXT_LENGTHS must cover the Grok family explicitly.
|
||||
# Values sourced from models.dev (2026-04).
|
||||
expected = {
|
||||
"grok-4.20": 2000000,
|
||||
"grok-4-1-fast": 2000000,
|
||||
"grok-4-fast": 2000000,
|
||||
"grok-4": 256000,
|
||||
"grok-code-fast": 256000,
|
||||
"grok-3": 131072,
|
||||
"grok-2": 131072,
|
||||
"grok-2-vision": 8192,
|
||||
"grok": 131072,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
assert key in DEFAULT_CONTEXT_LENGTHS, f"{key} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||
assert DEFAULT_CONTEXT_LENGTHS[key] == value, (
|
||||
f"{key} should be {value}, got {DEFAULT_CONTEXT_LENGTHS[key]}"
|
||||
)
|
||||
|
||||
def test_grok_substring_matching(self):
|
||||
# Longest-first substring matching must resolve the real xAI model
|
||||
# IDs to the correct fallback entries without 128k probe-down.
|
||||
from agent.model_metadata import get_model_context_length
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
# Fake the provider/API/cache layers so the lookup falls through
|
||||
# to DEFAULT_CONTEXT_LENGTHS.
|
||||
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), mock_patch("agent.model_metadata.get_cached_context_length", return_value=None):
|
||||
cases = [
|
||||
("grok-4.20-0309-reasoning", 2000000),
|
||||
("grok-4.20-0309-non-reasoning", 2000000),
|
||||
("grok-4.20-multi-agent-0309", 2000000),
|
||||
("grok-4-1-fast-reasoning", 2000000),
|
||||
("grok-4-1-fast-non-reasoning", 2000000),
|
||||
("grok-4-fast-reasoning", 2000000),
|
||||
("grok-4-fast-non-reasoning", 2000000),
|
||||
("grok-4", 256000),
|
||||
("grok-4-0709", 256000),
|
||||
("grok-code-fast-1", 256000),
|
||||
("grok-3", 131072),
|
||||
("grok-3-mini", 131072),
|
||||
("grok-3-mini-fast", 131072),
|
||||
("grok-2", 131072),
|
||||
("grok-2-vision", 8192),
|
||||
("grok-2-vision-1212", 8192),
|
||||
("grok-beta", 131072),
|
||||
]
|
||||
for model_id, expected_ctx in cases:
|
||||
actual = get_model_context_length(model_id)
|
||||
assert actual == expected_ctx, (
|
||||
f"{model_id}: expected {expected_ctx}, got {actual}"
|
||||
)
|
||||
|
||||
def test_all_values_positive(self):
|
||||
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
|
||||
assert value > 0, f"{key} has non-positive context length"
|
||||
|
||||
@@ -147,6 +147,20 @@ class TestEscapedSpaces:
|
||||
assert result["path"] == tmp_image_with_spaces
|
||||
assert result["remainder"] == "what is this?"
|
||||
|
||||
def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
img = home / "storage" / "shared" / "Pictures" / "cat.png"
|
||||
img.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
result = _detect_file_drop("~/storage/shared/Pictures/cat.png what is this?")
|
||||
|
||||
assert result is not None
|
||||
assert result["path"] == img
|
||||
assert result["is_image"] is True
|
||||
assert result["remainder"] == "what is this?"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: edge cases
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import (
|
||||
HermesCLI,
|
||||
_collect_query_images,
|
||||
_format_image_attachment_badges,
|
||||
_termux_example_image_path,
|
||||
)
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._attached_images = []
|
||||
return cli_obj
|
||||
|
||||
|
||||
def _make_image(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
return path
|
||||
|
||||
|
||||
class TestImageCommand:
|
||||
def test_handle_image_command_attaches_local_image(self, tmp_path):
|
||||
img = _make_image(tmp_path / "photo.png")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_image_command(f"/image {img}")
|
||||
|
||||
assert cli_obj._attached_images == [img]
|
||||
|
||||
def test_handle_image_command_supports_quoted_path_with_spaces(self, tmp_path):
|
||||
img = _make_image(tmp_path / "my photo.png")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_image_command(f'/image "{img}"')
|
||||
|
||||
assert cli_obj._attached_images == [img]
|
||||
|
||||
def test_handle_image_command_rejects_non_image_file(self, tmp_path):
|
||||
file_path = tmp_path / "notes.txt"
|
||||
file_path.write_text("hello\n", encoding="utf-8")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint") as mock_print:
|
||||
cli_obj._handle_image_command(f"/image {file_path}")
|
||||
|
||||
assert cli_obj._attached_images == []
|
||||
rendered = " ".join(str(arg) for call in mock_print.call_args_list for arg in call.args)
|
||||
assert "Not a supported image file" in rendered
|
||||
|
||||
|
||||
class TestCollectQueryImages:
|
||||
def test_collect_query_images_accepts_explicit_image_arg(self, tmp_path):
|
||||
img = _make_image(tmp_path / "diagram.png")
|
||||
|
||||
message, images = _collect_query_images("describe this", str(img))
|
||||
|
||||
assert message == "describe this"
|
||||
assert images == [img]
|
||||
|
||||
def test_collect_query_images_extracts_leading_path(self, tmp_path):
|
||||
img = _make_image(tmp_path / "camera.png")
|
||||
|
||||
message, images = _collect_query_images(f"{img} what do you see?")
|
||||
|
||||
assert message == "what do you see?"
|
||||
assert images == [img]
|
||||
|
||||
def test_collect_query_images_supports_tilde_paths(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
img = _make_image(home / "storage" / "shared" / "Pictures" / "cat.png")
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
message, images = _collect_query_images("describe this", "~/storage/shared/Pictures/cat.png")
|
||||
|
||||
assert message == "describe this"
|
||||
assert images == [img]
|
||||
|
||||
|
||||
class TestTermuxImageHints:
|
||||
def test_termux_example_image_path_prefers_real_shared_storage_root(self, monkeypatch):
|
||||
existing = {"/sdcard", "/storage/emulated/0"}
|
||||
monkeypatch.setattr("cli.os.path.isdir", lambda path: path in existing)
|
||||
|
||||
hint = _termux_example_image_path()
|
||||
|
||||
assert hint == "/sdcard/Pictures/cat.png"
|
||||
|
||||
|
||||
class TestImageBadgeFormatting:
|
||||
def test_compact_badges_use_filename_on_narrow_terminals(self, tmp_path):
|
||||
img = _make_image(tmp_path / "Screenshot 2026-04-09 at 11.22.33 AM.png")
|
||||
|
||||
badges = _format_image_attachment_badges([img], image_counter=1, width=40)
|
||||
|
||||
assert badges.startswith("[📎 ")
|
||||
assert "Image #1" not in badges
|
||||
|
||||
def test_compact_badges_summarize_multiple_images(self, tmp_path):
|
||||
img1 = _make_image(tmp_path / "one.png")
|
||||
img2 = _make_image(tmp_path / "two.png")
|
||||
|
||||
badges = _format_image_attachment_badges([img1, img2], image_counter=2, width=45)
|
||||
|
||||
assert badges == "[📎 2 images attached]"
|
||||
@@ -49,6 +49,25 @@ class TestCliSkinPromptIntegration:
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ❯ ")]
|
||||
|
||||
def test_narrow_terminals_compact_voice_prompt_fragments(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._voice_mode = True
|
||||
|
||||
with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50):
|
||||
assert cli._get_tui_prompt_fragments() == [("class:voice-prompt", "🎤 ")]
|
||||
|
||||
def test_narrow_terminals_compact_voice_recording_prompt_fragments(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._voice_recording = True
|
||||
cli._voice_recorder = SimpleNamespace(current_rms=3000)
|
||||
|
||||
with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50):
|
||||
frags = cli._get_tui_prompt_fragments()
|
||||
|
||||
assert frags[0][0] == "class:voice-recording"
|
||||
assert frags[0][1].startswith("●")
|
||||
assert "❯" not in frags[0][1]
|
||||
|
||||
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {"response_queue": object()}
|
||||
|
||||
@@ -206,6 +206,59 @@ class TestCLIStatusBar:
|
||||
assert "⚕" in text
|
||||
assert "claude-sonnet-4-20250514" in text
|
||||
|
||||
def test_minimal_tui_chrome_threshold(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._use_minimal_tui_chrome(width=63) is True
|
||||
assert cli_obj._use_minimal_tui_chrome(width=64) is False
|
||||
|
||||
def test_bottom_input_rule_hides_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._tui_input_rule_height("top", width=50) == 1
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=50) == 0
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=90) == 1
|
||||
|
||||
def test_agent_spacer_reclaimed_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._agent_running = True
|
||||
|
||||
assert cli_obj._agent_spacer_height(width=50) == 0
|
||||
assert cli_obj._agent_spacer_height(width=90) == 1
|
||||
cli_obj._agent_running = False
|
||||
assert cli_obj._agent_spacer_height(width=90) == 0
|
||||
|
||||
def test_spinner_line_hidden_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._spinner_text = "thinking"
|
||||
|
||||
assert cli_obj._spinner_widget_height(width=50) == 0
|
||||
assert cli_obj._spinner_widget_height(width=90) == 1
|
||||
cli_obj._spinner_text = ""
|
||||
assert cli_obj._spinner_widget_height(width=90) == 0
|
||||
|
||||
def test_voice_status_bar_compacts_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = False
|
||||
cli_obj._voice_processing = False
|
||||
cli_obj._voice_tts = True
|
||||
cli_obj._voice_continuous = True
|
||||
|
||||
fragments = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")]
|
||||
|
||||
def test_voice_recording_status_bar_compacts_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = True
|
||||
cli_obj._voice_processing = False
|
||||
|
||||
fragments = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert fragments == [("class:voice-status-recording", " ● REC ")]
|
||||
|
||||
|
||||
class TestCLIUsageReport:
|
||||
def test_show_usage_includes_estimated_cost(self, capsys):
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for CLI /status command behavior."""
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.config = {}
|
||||
cli_obj.console = MagicMock()
|
||||
cli_obj.agent = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.session_id = "session-123"
|
||||
cli_obj._pending_input = MagicMock()
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj.model = "openai/gpt-5.4"
|
||||
cli_obj.provider = "openai"
|
||||
cli_obj.session_start = datetime(2026, 4, 9, 19, 24)
|
||||
cli_obj._agent_running = False
|
||||
cli_obj._session_db = MagicMock()
|
||||
cli_obj._session_db.get_session.return_value = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_status_command_is_available_in_cli_registry():
|
||||
cmd = resolve_command("status")
|
||||
assert cmd is not None
|
||||
assert cmd.gateway_only is False
|
||||
|
||||
|
||||
def test_process_command_status_dispatches_without_toggling_status_bar():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch.object(cli_obj, "_show_session_status", create=True) as mock_status:
|
||||
assert cli_obj.process_command("/status") is True
|
||||
|
||||
mock_status.assert_called_once_with()
|
||||
assert cli_obj._status_bar_visible is True
|
||||
|
||||
|
||||
def test_statusbar_still_toggles_visibility():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj.process_command("/statusbar") is True
|
||||
assert cli_obj._status_bar_visible is False
|
||||
|
||||
|
||||
def test_status_prefix_prefers_status_command_over_statusbar_toggle():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch.object(cli_obj, "_show_session_status") as mock_status:
|
||||
assert cli_obj.process_command("/sta") is True
|
||||
|
||||
mock_status.assert_called_once_with()
|
||||
assert cli_obj._status_bar_visible is True
|
||||
|
||||
|
||||
def test_show_session_status_prints_gateway_style_summary():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.agent = SimpleNamespace(
|
||||
session_total_tokens=321,
|
||||
session_api_calls=4,
|
||||
)
|
||||
cli_obj._session_db.get_session.return_value = {
|
||||
"title": "My titled session",
|
||||
"started_at": 1775791440,
|
||||
}
|
||||
|
||||
with patch("cli.display_hermes_home", return_value="~/.hermes"):
|
||||
cli_obj._show_session_status()
|
||||
|
||||
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
|
||||
assert "Hermes CLI Status" in printed
|
||||
assert "Session ID: session-123" in printed
|
||||
assert "Path: ~/.hermes" in printed
|
||||
assert "Title: My titled session" in printed
|
||||
assert "Model: openai/gpt-5.4 (openai)" in printed
|
||||
assert "Tokens: 321" in printed
|
||||
assert "Agent Running: No" in printed
|
||||
_, kwargs = cli_obj.console.print.call_args
|
||||
assert kwargs.get("highlight") is False
|
||||
assert kwargs.get("markup") is False
|
||||
@@ -0,0 +1,413 @@
|
||||
"""Tests for the /fast CLI command and service-tier config handling."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _import_cli():
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
if not hasattr(config_mod, "save_env_value_secure"):
|
||||
config_mod.save_env_value_secure = lambda key, value: {
|
||||
"success": True,
|
||||
"stored_as": key,
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod
|
||||
|
||||
|
||||
class TestParseServiceTierConfig(unittest.TestCase):
|
||||
def _parse(self, raw):
|
||||
cli_mod = _import_cli()
|
||||
return cli_mod._parse_service_tier_config(raw)
|
||||
|
||||
def test_fast_maps_to_priority(self):
|
||||
self.assertEqual(self._parse("fast"), "priority")
|
||||
self.assertEqual(self._parse("priority"), "priority")
|
||||
|
||||
def test_normal_disables_service_tier(self):
|
||||
self.assertIsNone(self._parse("normal"))
|
||||
self.assertIsNone(self._parse("off"))
|
||||
self.assertIsNone(self._parse(""))
|
||||
|
||||
|
||||
class TestHandleFastCommand(unittest.TestCase):
|
||||
def _make_cli(self, service_tier=None):
|
||||
return SimpleNamespace(
|
||||
service_tier=service_tier,
|
||||
provider="openai-codex",
|
||||
requested_provider="openai-codex",
|
||||
model="gpt-5.4",
|
||||
_fast_command_available=lambda: True,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
|
||||
def test_no_args_shows_status(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier=None)
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
# Bare /fast shows status, does not change config
|
||||
mock_save.assert_not_called()
|
||||
# Should have printed the status line
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("normal", printed)
|
||||
|
||||
def test_no_args_shows_fast_when_enabled(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier="priority")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("fast", printed)
|
||||
|
||||
def test_normal_argument_clears_service_tier(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier="priority")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint"),
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal")
|
||||
|
||||
mock_save.assert_called_once_with("agent.service_tier", "normal")
|
||||
self.assertIsNone(stub.service_tier)
|
||||
self.assertIsNone(stub.agent)
|
||||
|
||||
def test_unsupported_model_does_not_expose_fast(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
service_tier=None,
|
||||
provider="openai-codex",
|
||||
requested_provider="openai-codex",
|
||||
model="gpt-5.3-codex",
|
||||
_fast_command_available=lambda: False,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
self.assertTrue(mock_cprint.called)
|
||||
|
||||
|
||||
class TestPriorityProcessingModels(unittest.TestCase):
|
||||
"""Verify the expanded Priority Processing model registry."""
|
||||
|
||||
def test_all_documented_models_supported(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# All models from OpenAI's Priority Processing pricing table
|
||||
supported = [
|
||||
"gpt-5.4", "gpt-5.4-mini", "gpt-5.2",
|
||||
"gpt-5.1", "gpt-5", "gpt-5-mini",
|
||||
"gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
|
||||
"gpt-4o", "gpt-4o-mini",
|
||||
"o3", "o4-mini",
|
||||
]
|
||||
for model in supported:
|
||||
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
||||
|
||||
def test_vendor_prefix_stripped(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("openai/gpt-5.4") is True
|
||||
assert model_supports_fast_mode("openai/gpt-4.1") is True
|
||||
assert model_supports_fast_mode("openai/o3") is True
|
||||
|
||||
def test_non_priority_models_rejected(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("gpt-5.3-codex") is False
|
||||
assert model_supports_fast_mode("claude-sonnet-4") is False
|
||||
assert model_supports_fast_mode("") is False
|
||||
assert model_supports_fast_mode(None) is False
|
||||
|
||||
def test_resolve_overrides_returns_service_tier(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-5.4")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-4.1")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
def test_resolve_overrides_none_for_unsupported(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
assert resolve_fast_mode_overrides("gpt-5.3-codex") is None
|
||||
assert resolve_fast_mode_overrides("claude-sonnet-4") is None
|
||||
|
||||
|
||||
class TestFastModeRouting(unittest.TestCase):
|
||||
def test_fast_command_exposed_for_model_even_when_provider_is_auto(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(provider="auto", requested_provider="auto", model="gpt-5.4", agent=None)
|
||||
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_fast_command_exposed_for_non_codex_models(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(provider="openai", requested_provider="openai", model="gpt-4.1", agent=None)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
stub = SimpleNamespace(provider="openrouter", requested_provider="openrouter", model="o3", agent=None)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_turn_route_injects_overrides_without_provider_switch(self):
|
||||
"""Fast mode should add request_overrides but NOT change the provider/runtime."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="gpt-5.4",
|
||||
api_key="primary-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
api_mode="chat_completions",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
# Provider should NOT have changed
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route["runtime"]["api_mode"] == "chat_completions"
|
||||
# But request_overrides should be set
|
||||
assert route["request_overrides"] == {"service_tier": "priority"}
|
||||
|
||||
def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="gpt-5.3-codex",
|
||||
api_key="primary-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
api_mode="chat_completions",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
primary_route = {
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
},
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value=primary_route):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route.get("request_overrides") is None
|
||||
|
||||
|
||||
class TestAnthropicFastMode(unittest.TestCase):
|
||||
"""Verify Anthropic Fast Mode model support and override resolution."""
|
||||
|
||||
def test_anthropic_opus_supported(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# Native Anthropic format (hyphens)
|
||||
assert model_supports_fast_mode("claude-opus-4-6") is True
|
||||
# OpenRouter format (dots)
|
||||
assert model_supports_fast_mode("claude-opus-4.6") is True
|
||||
# With vendor prefix
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
|
||||
|
||||
def test_anthropic_non_opus_rejected(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("claude-sonnet-4-6") is False
|
||||
assert model_supports_fast_mode("claude-sonnet-4.6") is False
|
||||
assert model_supports_fast_mode("claude-haiku-4-5") is False
|
||||
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
|
||||
|
||||
def test_anthropic_variant_tags_stripped(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# OpenRouter variant tags after colon should be stripped
|
||||
assert model_supports_fast_mode("claude-opus-4.6:fast") is True
|
||||
assert model_supports_fast_mode("claude-opus-4.6:beta") is True
|
||||
|
||||
def test_resolve_overrides_returns_speed_for_anthropic(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("claude-opus-4-6")
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
def test_resolve_overrides_returns_service_tier_for_openai(self):
|
||||
"""OpenAI models should still get service_tier, not speed."""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-5.4")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
def test_is_anthropic_fast_model(self):
|
||||
from hermes_cli.models import _is_anthropic_fast_model
|
||||
|
||||
assert _is_anthropic_fast_model("claude-opus-4-6") is True
|
||||
assert _is_anthropic_fast_model("claude-opus-4.6") is True
|
||||
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
|
||||
assert _is_anthropic_fast_model("gpt-5.4") is False
|
||||
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
|
||||
|
||||
def test_fast_command_exposed_for_anthropic_model(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
model="claude-opus-4-6", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_fast_command_hidden_for_anthropic_sonnet(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
model="claude-sonnet-4-6", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is False
|
||||
|
||||
def test_turn_route_injects_speed_for_anthropic(self):
|
||||
"""Anthropic models should get speed:'fast' override, not service_tier."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="claude-opus-4-6",
|
||||
api_key="sk-ant-test",
|
||||
base_url="https://api.anthropic.com",
|
||||
provider="anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
_smart_model_routing={},
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
original_runtime = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"provider": "anthropic",
|
||||
"api_mode": "anthropic_messages",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "claude-opus-4-6",
|
||||
"runtime": dict(original_runtime),
|
||||
"label": None,
|
||||
"signature": ("claude-opus-4-6", "anthropic", "https://api.anthropic.com", "anthropic_messages", None, ()),
|
||||
}):
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "anthropic"
|
||||
assert route["request_overrides"] == {"speed": "fast"}
|
||||
|
||||
|
||||
class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
|
||||
|
||||
def test_fast_mode_adds_speed_and_beta(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs, _FAST_MODE_BETA
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
assert kwargs.get("speed") == "fast"
|
||||
assert "extra_headers" in kwargs
|
||||
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
|
||||
|
||||
def test_fast_mode_off_no_speed(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=False,
|
||||
)
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
def test_fast_mode_skipped_for_third_party_endpoint(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
base_url="https://api.minimax.io/anthropic/v1",
|
||||
)
|
||||
# Third-party endpoints should NOT get speed or fast-mode beta
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
|
||||
class TestConfigDefault(unittest.TestCase):
|
||||
def test_default_config_has_service_tier(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
agent = DEFAULT_CONFIG.get("agent", {})
|
||||
self.assertIn("service_tier", agent)
|
||||
self.assertEqual(agent["service_tier"], "")
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for _stream_delta's handling of <think> tags in prose vs real reasoning blocks."""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
"""Create a minimal HermesCLI-like object with stream state."""
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = False
|
||||
cli._stream_buf = ""
|
||||
cli._stream_started = False
|
||||
cli._stream_box_opened = False
|
||||
cli._stream_prefilt = ""
|
||||
cli._in_reasoning_block = False
|
||||
cli._reasoning_stream_started = False
|
||||
cli._reasoning_box_opened = False
|
||||
cli._reasoning_buf = ""
|
||||
cli._reasoning_preview_buf = ""
|
||||
cli._deferred_content = ""
|
||||
cli._stream_text_ansi = ""
|
||||
cli._stream_needs_break = False
|
||||
cli._emitted = []
|
||||
|
||||
# Mock _emit_stream_text to capture output
|
||||
def mock_emit(text):
|
||||
cli._emitted.append(text)
|
||||
cli._emit_stream_text = mock_emit
|
||||
|
||||
# Mock _stream_reasoning_delta
|
||||
cli._reasoning_emitted = []
|
||||
def mock_reasoning(text):
|
||||
cli._reasoning_emitted.append(text)
|
||||
cli._stream_reasoning_delta = mock_reasoning
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
class TestThinkTagInProse:
|
||||
"""<think> mentioned in prose should NOT trigger reasoning suppression."""
|
||||
|
||||
def test_think_tag_mid_sentence(self):
|
||||
"""'(/think not producing <think> tags)' should pass through."""
|
||||
cli = _make_cli_stub()
|
||||
tokens = [
|
||||
" 1. Fix reasoning mode in eval ",
|
||||
"(/think not producing ",
|
||||
"<think>",
|
||||
" tags — ~2% gap)",
|
||||
"\n 2. Launch production",
|
||||
]
|
||||
for t in tokens:
|
||||
cli._stream_delta(t)
|
||||
assert not cli._in_reasoning_block, "<think> in prose should not enter reasoning block"
|
||||
full = "".join(cli._emitted)
|
||||
assert "<think>" in full, "The literal <think> tag should be in the emitted text"
|
||||
assert "Launch production" in full
|
||||
|
||||
def test_think_tag_after_text_on_same_line(self):
|
||||
"""'some text <think>' should NOT trigger reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Here is the <think> tag explanation")
|
||||
assert not cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "<think>" in full
|
||||
|
||||
def test_think_tag_in_backticks(self):
|
||||
"""'`<think>`' should NOT trigger reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Use the `<think>` tag for reasoning")
|
||||
assert not cli._in_reasoning_block
|
||||
|
||||
|
||||
class TestRealReasoningBlock:
|
||||
"""Real <think> tags at block boundaries should still be caught."""
|
||||
|
||||
def test_think_at_start_of_stream(self):
|
||||
"""'<think>reasoning</think>answer' should suppress reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("<think>")
|
||||
assert cli._in_reasoning_block
|
||||
cli._stream_delta("I need to analyze this")
|
||||
cli._stream_delta("</think>")
|
||||
assert not cli._in_reasoning_block
|
||||
cli._stream_delta("Here is my answer")
|
||||
full = "".join(cli._emitted)
|
||||
assert "Here is my answer" in full
|
||||
assert "I need to analyze" not in full # reasoning was suppressed
|
||||
|
||||
def test_think_after_newline(self):
|
||||
"""'text\\n<think>' should trigger reasoning block."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Some preamble\n<think>")
|
||||
assert cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "Some preamble" in full
|
||||
|
||||
def test_think_after_newline_with_whitespace(self):
|
||||
"""'text\\n <think>' should trigger reasoning block."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Some preamble\n <think>")
|
||||
assert cli._in_reasoning_block
|
||||
|
||||
def test_think_with_only_whitespace_before(self):
|
||||
"""' <think>' (whitespace only prefix) should trigger."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta(" <think>")
|
||||
assert cli._in_reasoning_block
|
||||
|
||||
|
||||
class TestFlushRecovery:
|
||||
"""_flush_stream should recover content from false-positive reasoning blocks."""
|
||||
|
||||
def test_flush_recovers_buffered_content(self):
|
||||
"""If somehow in reasoning block at flush, content is recovered."""
|
||||
cli = _make_cli_stub()
|
||||
# Manually set up a false-positive state
|
||||
cli._in_reasoning_block = True
|
||||
cli._stream_prefilt = " tags — ~2% gap)\n 2. Launch production"
|
||||
cli._stream_box_opened = True
|
||||
|
||||
# Mock _close_reasoning_box and box closing
|
||||
cli._close_reasoning_box = lambda: None
|
||||
|
||||
# Call flush
|
||||
from unittest.mock import patch
|
||||
import shutil
|
||||
with patch.object(shutil, "get_terminal_size", return_value=os.terminal_size((80, 24))):
|
||||
with patch("cli._cprint"):
|
||||
cli._flush_stream()
|
||||
|
||||
assert not cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "Launch production" in full
|
||||
@@ -173,6 +173,40 @@ class TestResolveDeliveryTarget:
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_explicit_discord_topic_target_with_thread_id(self):
|
||||
"""deliver: 'discord:chat_id:thread_id' parses correctly."""
|
||||
job = {
|
||||
"deliver": "discord:-1001234567890:17585",
|
||||
}
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "discord",
|
||||
"chat_id": "-1001234567890",
|
||||
"thread_id": "17585",
|
||||
}
|
||||
|
||||
def test_explicit_discord_chat_id_without_thread_id(self):
|
||||
"""deliver: 'discord:chat_id' sets thread_id to None."""
|
||||
job = {
|
||||
"deliver": "discord:9876543210",
|
||||
}
|
||||
assert _resolve_delivery_target(job) == {
|
||||
"platform": "discord",
|
||||
"chat_id": "9876543210",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
def test_explicit_discord_channel_without_thread(self):
|
||||
"""deliver: 'discord:1001234567890' resolves via explicit platform:chat_id path."""
|
||||
job = {
|
||||
"deliver": "discord:1001234567890",
|
||||
}
|
||||
result = _resolve_delivery_target(job)
|
||||
assert result == {
|
||||
"platform": "discord",
|
||||
"chat_id": "1001234567890",
|
||||
"thread_id": None,
|
||||
}
|
||||
|
||||
|
||||
class TestDeliverResultWrapping:
|
||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||
|
||||
@@ -105,10 +105,6 @@ class TestTelegramSlashCommands:
|
||||
send_status.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail(
|
||||
reason="Bug: _handle_provider_command references unbound model_cfg when config.yaml is absent",
|
||||
strict=False,
|
||||
)
|
||||
async def test_provider_shows_current_provider(self, adapter):
|
||||
send = await send_and_capture(adapter, "/provider")
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from gateway.platforms.api_server import (
|
||||
APIServerAdapter,
|
||||
ResponseStore,
|
||||
_CORS_HEADERS,
|
||||
_derive_chat_session_id,
|
||||
check_api_server_requirements,
|
||||
cors_middleware,
|
||||
security_headers_middleware,
|
||||
@@ -294,6 +295,40 @@ class TestModelsEndpoint:
|
||||
assert data["data"][0]["id"] == "hermes-agent"
|
||||
assert data["data"][0]["owned_by"] == "hermes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_models_returns_profile_name(self):
|
||||
"""When running under a named profile, /v1/models advertises the profile name."""
|
||||
with patch("gateway.platforms.api_server.APIServerAdapter._resolve_model_name", return_value="lucas"):
|
||||
adapter = _make_adapter()
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/models")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["data"][0]["id"] == "lucas"
|
||||
assert data["data"][0]["root"] == "lucas"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_models_returns_explicit_model_name(self):
|
||||
"""Explicit model_name in config overrides profile name."""
|
||||
extra = {"model_name": "my-custom-agent"}
|
||||
config = PlatformConfig(enabled=True, extra=extra)
|
||||
adapter = APIServerAdapter(config)
|
||||
assert adapter._model_name == "my-custom-agent"
|
||||
|
||||
def test_resolve_model_name_explicit(self):
|
||||
assert APIServerAdapter._resolve_model_name("my-bot") == "my-bot"
|
||||
|
||||
def test_resolve_model_name_default_profile(self):
|
||||
"""Default profile falls back to 'hermes-agent'."""
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
|
||||
assert APIServerAdapter._resolve_model_name("") == "hermes-agent"
|
||||
|
||||
def test_resolve_model_name_named_profile(self):
|
||||
"""Named profile uses the profile name as model name."""
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="lucas"):
|
||||
assert APIServerAdapter._resolve_model_name("") == "lucas"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_models_requires_auth(self, auth_adapter):
|
||||
app = _create_app(auth_adapter)
|
||||
@@ -624,6 +659,98 @@ class TestChatCompletionsEndpoint:
|
||||
data = await resp.json()
|
||||
assert "Provider failed" in data["error"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stable_session_id_across_turns(self, adapter):
|
||||
"""Same conversation (same first user message) produces the same session_id."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
|
||||
app = _create_app(adapter)
|
||||
session_ids = []
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
# Turn 1: single user message
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
session_ids.append(mock_run.call_args.kwargs["session_id"])
|
||||
|
||||
# Turn 2: same first message, conversation grew
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
],
|
||||
},
|
||||
)
|
||||
session_ids.append(mock_run.call_args.kwargs["session_id"])
|
||||
|
||||
assert session_ids[0] == session_ids[1], "Session ID should be stable across turns"
|
||||
assert session_ids[0].startswith("api-"), "Derived session IDs should have api- prefix"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_conversations_get_different_session_ids(self, adapter):
|
||||
"""Different first messages produce different session_ids."""
|
||||
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}
|
||||
|
||||
app = _create_app(adapter)
|
||||
session_ids = []
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
for first_msg in ["Hello", "Goodbye"]:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
await cli.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
"messages": [{"role": "user", "content": first_msg}],
|
||||
},
|
||||
)
|
||||
session_ids.append(mock_run.call_args.kwargs["session_id"])
|
||||
|
||||
assert session_ids[0] != session_ids[1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _derive_chat_session_id unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeriveChatSessionId:
|
||||
def test_deterministic(self):
|
||||
"""Same inputs always produce the same session ID."""
|
||||
a = _derive_chat_session_id("sys", "hello")
|
||||
b = _derive_chat_session_id("sys", "hello")
|
||||
assert a == b
|
||||
|
||||
def test_prefix(self):
|
||||
assert _derive_chat_session_id(None, "hi").startswith("api-")
|
||||
|
||||
def test_different_system_prompt(self):
|
||||
a = _derive_chat_session_id("You are a pirate.", "Hello")
|
||||
b = _derive_chat_session_id("You are a robot.", "Hello")
|
||||
assert a != b
|
||||
|
||||
def test_different_first_message(self):
|
||||
a = _derive_chat_session_id(None, "Hello")
|
||||
b = _derive_chat_session_id(None, "Goodbye")
|
||||
assert a != b
|
||||
|
||||
def test_none_system_prompt(self):
|
||||
"""None system prompt doesn't crash."""
|
||||
sid = _derive_chat_session_id(None, "test")
|
||||
assert isinstance(sid, str) and len(sid) > 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /v1/responses endpoint
|
||||
@@ -1600,7 +1727,7 @@ class TestSessionIdHeader:
|
||||
assert resp.headers.get("X-Hermes-Session-Id") is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provided_session_id_is_used_and_echoed(self, adapter):
|
||||
async def test_provided_session_id_is_used_and_echoed(self, auth_adapter):
|
||||
"""When X-Hermes-Session-Id is provided, it's passed to the agent and echoed in the response."""
|
||||
mock_result = {"final_response": "Continuing!", "messages": [], "api_calls": 1}
|
||||
mock_db = MagicMock()
|
||||
@@ -1608,15 +1735,15 @@ class TestSessionIdHeader:
|
||||
{"role": "user", "content": "previous message"},
|
||||
{"role": "assistant", "content": "previous reply"},
|
||||
]
|
||||
adapter._session_db = mock_db
|
||||
app = _create_app(adapter)
|
||||
auth_adapter._session_db = mock_db
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Id": "my-session-123"},
|
||||
headers={"X-Hermes-Session-Id": "my-session-123", "Authorization": "Bearer sk-secret"},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Continue"}]},
|
||||
)
|
||||
|
||||
@@ -1626,7 +1753,7 @@ class TestSessionIdHeader:
|
||||
assert call_kwargs["session_id"] == "my-session-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provided_session_id_loads_history_from_db(self, adapter):
|
||||
async def test_provided_session_id_loads_history_from_db(self, auth_adapter):
|
||||
"""When X-Hermes-Session-Id is provided, history comes from SessionDB not request body."""
|
||||
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
||||
db_history = [
|
||||
@@ -1635,15 +1762,15 @@ class TestSessionIdHeader:
|
||||
]
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_messages_as_conversation.return_value = db_history
|
||||
adapter._session_db = mock_db
|
||||
app = _create_app(adapter)
|
||||
auth_adapter._session_db = mock_db
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Id": "existing-session"},
|
||||
headers={"X-Hermes-Session-Id": "existing-session", "Authorization": "Bearer sk-secret"},
|
||||
# Request body has different history — should be ignored
|
||||
json={
|
||||
"model": "hermes-agent",
|
||||
@@ -1662,20 +1789,20 @@ class TestSessionIdHeader:
|
||||
assert call_kwargs["user_message"] == "new question"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_failure_falls_back_to_empty_history(self, adapter):
|
||||
async def test_db_failure_falls_back_to_empty_history(self, auth_adapter):
|
||||
"""If SessionDB raises, history falls back to empty and request still succeeds."""
|
||||
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
|
||||
# Simulate DB failure: _session_db is None and SessionDB() constructor raises
|
||||
adapter._session_db = None
|
||||
app = _create_app(adapter)
|
||||
auth_adapter._session_db = None
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run, \
|
||||
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run, \
|
||||
patch("hermes_state.SessionDB", side_effect=Exception("DB unavailable")):
|
||||
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
|
||||
resp = await cli.post(
|
||||
"/v1/chat/completions",
|
||||
headers={"X-Hermes-Session-Id": "some-session"},
|
||||
headers={"X-Hermes-Session-Id": "some-session", "Authorization": "Bearer sk-secret"},
|
||||
json={"model": "hermes-agent", "messages": [{"role": "user", "content": "Hi"}]},
|
||||
)
|
||||
|
||||
|
||||
@@ -308,6 +308,7 @@ class TestBackgroundInCLICommands:
|
||||
|
||||
def test_background_autocompletes(self):
|
||||
"""The /background command appears in autocomplete results."""
|
||||
pytest.importorskip("prompt_toolkit")
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, ProcessingOutcome, SendResult
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ class DummyTelegramAdapter(BasePlatformAdapter):
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
self.processing_hooks.append(("start", event.message_id))
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
|
||||
self.processing_hooks.append(("complete", event.message_id, success))
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||||
self.processing_hooks.append(("complete", event.message_id, outcome))
|
||||
|
||||
|
||||
def _make_event(chat_id: str, thread_id: str, message_id: str = "1") -> MessageEvent:
|
||||
@@ -142,7 +142,7 @@ class TestBasePlatformTopicSessions:
|
||||
]
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", True),
|
||||
("complete", "1", ProcessingOutcome.SUCCESS),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -168,7 +168,7 @@ class TestBasePlatformTopicSessions:
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
("complete", "1", ProcessingOutcome.FAILURE),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -190,7 +190,7 @@ class TestBasePlatformTopicSessions:
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
("complete", "1", ProcessingOutcome.FAILURE),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -218,5 +218,31 @@ class TestBasePlatformTopicSessions:
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", False),
|
||||
("complete", "1", ProcessingOutcome.FAILURE),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_background_tasks_marks_expected_cancellation_cancelled(self):
|
||||
adapter = DummyTelegramAdapter()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def handler(_event):
|
||||
await release.wait()
|
||||
return "ack"
|
||||
|
||||
async def hold_typing(_chat_id, interval=2.0, metadata=None):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
adapter._keep_typing = hold_typing
|
||||
|
||||
event = _make_event("-1001", "17585")
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await adapter.cancel_background_tasks()
|
||||
|
||||
assert adapter.processing_hooks == [
|
||||
("start", "1"),
|
||||
("complete", "1", ProcessingOutcome.CANCELLED),
|
||||
]
|
||||
|
||||
@@ -359,3 +359,257 @@ class TestBlueBubblesAttachmentDownload:
|
||||
adapter._download_attachment("att-guid", {"mimeType": "image/png"})
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookUrl:
|
||||
"""_webhook_url property normalises local hosts to 'localhost'."""
|
||||
|
||||
def test_default_host(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
# Default webhook_host is 0.0.0.0 → normalized to localhost
|
||||
assert "localhost" in adapter._webhook_url
|
||||
assert str(adapter.webhook_port) in adapter._webhook_url
|
||||
assert adapter.webhook_path in adapter._webhook_url
|
||||
|
||||
@pytest.mark.parametrize("host", ["0.0.0.0", "127.0.0.1", "localhost", "::"])
|
||||
def test_local_hosts_normalized(self, monkeypatch, host):
|
||||
adapter = _make_adapter(monkeypatch, webhook_host=host)
|
||||
assert adapter._webhook_url.startswith("http://localhost:")
|
||||
|
||||
def test_custom_host_preserved(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50")
|
||||
assert "192.168.1.50" in adapter._webhook_url
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookRegistration:
|
||||
"""Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks."""
|
||||
|
||||
@staticmethod
|
||||
def _mock_client(get_response=None, post_response=None, delete_ok=True):
|
||||
"""Build a tiny mock httpx.AsyncClient."""
|
||||
|
||||
async def mock_get(*args, **kwargs):
|
||||
class R:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return get_response or {"status": 200, "data": []}
|
||||
return R()
|
||||
|
||||
async def mock_post(*args, **kwargs):
|
||||
class R:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
def json(self):
|
||||
return post_response or {"status": 200, "data": {}}
|
||||
return R()
|
||||
|
||||
async def mock_delete(*args, **kwargs):
|
||||
class R:
|
||||
status_code = 200 if delete_ok else 500
|
||||
def raise_for_status(self_inner):
|
||||
if not delete_ok:
|
||||
raise Exception("delete failed")
|
||||
return R()
|
||||
|
||||
return type(
|
||||
"MockClient", (),
|
||||
{"get": mock_get, "post": mock_post, "delete": mock_delete},
|
||||
)()
|
||||
|
||||
# -- _find_registered_webhooks --
|
||||
|
||||
def test_find_registered_webhooks_returns_matches(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 1, "url": url, "events": ["new-message"]},
|
||||
{"id": 2, "url": "http://other:9999/hook", "events": ["message"]},
|
||||
]}
|
||||
)
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._find_registered_webhooks(url)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == 1
|
||||
|
||||
def test_find_registered_webhooks_empty_when_none(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []}
|
||||
)
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._find_registered_webhooks(adapter._webhook_url)
|
||||
)
|
||||
assert result == []
|
||||
|
||||
def test_find_registered_webhooks_handles_api_error(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client()
|
||||
|
||||
# Override _api_get to raise
|
||||
async def bad_get(path):
|
||||
raise ConnectionError("server down")
|
||||
adapter._api_get = bad_get
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._find_registered_webhooks(adapter._webhook_url)
|
||||
)
|
||||
assert result == []
|
||||
|
||||
# -- _register_webhook --
|
||||
|
||||
def test_register_fresh(self, monkeypatch):
|
||||
"""No existing webhook → POST creates one."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []},
|
||||
post_response={"status": 200, "data": {"id": 42}},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_register_accepts_201(self, monkeypatch):
|
||||
"""BB might return 201 Created — must still succeed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []},
|
||||
post_response={"status": 201, "data": {"id": 43}},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_register_reuses_existing(self, monkeypatch):
|
||||
"""Crash resilience — existing registration is reused, no POST needed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 7, "url": url, "events": ["new-message"]},
|
||||
]},
|
||||
)
|
||||
|
||||
# Track whether POST was called
|
||||
post_called = False
|
||||
orig_api_post = adapter._api_post
|
||||
async def tracking_post(path, payload):
|
||||
nonlocal post_called
|
||||
post_called = True
|
||||
return await orig_api_post(path, payload)
|
||||
adapter._api_post = tracking_post
|
||||
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
assert not post_called, "Should reuse existing, not POST again"
|
||||
|
||||
def test_register_returns_false_without_client(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = None
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
def test_register_returns_false_on_server_error(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": []},
|
||||
post_response={"status": 500, "message": "internal error"},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._register_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
# -- _unregister_webhook --
|
||||
|
||||
def test_unregister_removes_matching(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 10, "url": url},
|
||||
]},
|
||||
)
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
def test_unregister_removes_all_duplicates(self, monkeypatch):
|
||||
"""Multiple orphaned registrations for same URL — all get removed."""
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
url = adapter._webhook_url
|
||||
deleted_ids = []
|
||||
|
||||
async def mock_delete(*args, **kwargs):
|
||||
# Extract ID from URL
|
||||
url_str = args[0] if args else ""
|
||||
deleted_ids.append(url_str)
|
||||
class R:
|
||||
status_code = 200
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
return R()
|
||||
|
||||
adapter.client = self._mock_client(
|
||||
get_response={"status": 200, "data": [
|
||||
{"id": 1, "url": url},
|
||||
{"id": 2, "url": url},
|
||||
{"id": 3, "url": "http://other/hook"},
|
||||
]},
|
||||
)
|
||||
adapter.client.delete = mock_delete
|
||||
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is True
|
||||
assert len(deleted_ids) == 2
|
||||
|
||||
def test_unregister_returns_false_without_client(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = None
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
def test_unregister_handles_api_failure_gracefully(self, monkeypatch):
|
||||
import asyncio
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
adapter.client = self._mock_client()
|
||||
|
||||
async def bad_get(path):
|
||||
raise ConnectionError("server down")
|
||||
adapter._api_get = bad_get
|
||||
|
||||
ok = asyncio.get_event_loop().run_until_complete(
|
||||
adapter._unregister_webhook()
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
@@ -160,6 +160,22 @@ class TestCommandBypassActiveSession:
|
||||
assert sk not in adapter._pending_messages
|
||||
assert any("handled:status" in r for r in adapter.sent_responses)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_bypasses_guard(self):
|
||||
"""/background must bypass so it spawns a parallel task, not an interrupt."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event("/background summarize HN"))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
"/background was queued as a pending message instead of being dispatched"
|
||||
)
|
||||
assert any("handled:background" in r for r in adapter.sent_responses), (
|
||||
"/background response was not sent back to the user"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: non-bypass messages still get queued
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the delivery routing module."""
|
||||
|
||||
from gateway.config import Platform, GatewayConfig, PlatformConfig, HomeChannel
|
||||
from gateway.delivery import DeliveryRouter, DeliveryTarget
|
||||
from gateway.config import Platform
|
||||
from gateway.delivery import DeliveryTarget
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
@@ -65,10 +65,4 @@ class TestTargetToStringRoundtrip:
|
||||
assert reparsed.chat_id == "999"
|
||||
|
||||
|
||||
class TestDeliveryRouter:
|
||||
def test_resolve_targets_does_not_duplicate_local_when_explicit(self):
|
||||
router = DeliveryRouter(GatewayConfig(always_log_local=True))
|
||||
|
||||
targets = router.resolve_targets(["local"])
|
||||
|
||||
assert [target.platform for target in targets] == [Platform.LOCAL]
|
||||
|
||||
@@ -81,6 +81,7 @@ def adapter(monkeypatch):
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for Discord channel_skill_bindings auto-skill resolution."""
|
||||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a minimal DiscordAdapter with mocked config."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
return adapter
|
||||
|
||||
|
||||
class TestResolveChannelSkills:
|
||||
def test_no_bindings_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter._resolve_channel_skills("123") is None
|
||||
|
||||
def test_match_by_channel_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skills": ["skill-a", "skill-b"]},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("100") == ["skill-a", "skill-b"]
|
||||
|
||||
def test_match_by_parent_id(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "200", "skills": ["forum-skill"]},
|
||||
]
|
||||
}
|
||||
# channel_id doesn't match, but parent_id does (forum thread)
|
||||
assert adapter._resolve_channel_skills("999", parent_id="200") == ["forum-skill"]
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skills": ["skill-a"]},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("999") is None
|
||||
|
||||
def test_single_skill_string(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skill": "solo-skill"},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("100") == ["solo-skill"]
|
||||
|
||||
def test_dedup_preserves_order(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.config.extra = {
|
||||
"channel_skill_bindings": [
|
||||
{"id": "100", "skills": ["a", "b", "a", "c", "b"]},
|
||||
]
|
||||
}
|
||||
assert adapter._resolve_channel_skills("100") == ["a", "b", "c"]
|
||||
@@ -91,6 +91,7 @@ def adapter(monkeypatch):
|
||||
config = PlatformConfig(enabled=True, token="fake-token")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SendResult
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome, SendResult
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ async def test_reactions_disabled_via_env_zero(adapter, monkeypatch):
|
||||
|
||||
event = _make_event("5", raw_message)
|
||||
await adapter.on_processing_start(event)
|
||||
await adapter.on_processing_complete(event, success=True)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
raw_message.add_reaction.assert_not_awaited()
|
||||
raw_message.remove_reaction.assert_not_awaited()
|
||||
@@ -232,3 +232,17 @@ async def test_reactions_enabled_by_default(adapter, monkeypatch):
|
||||
await adapter.on_processing_start(event)
|
||||
|
||||
raw_message.add_reaction.assert_awaited_once_with("👀")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_cancelled_removes_eyes_without_terminal_reaction(adapter):
|
||||
raw_message = SimpleNamespace(
|
||||
add_reaction=AsyncMock(),
|
||||
remove_reaction=AsyncMock(),
|
||||
)
|
||||
|
||||
event = _make_event("7", raw_message)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
|
||||
|
||||
raw_message.remove_reaction.assert_awaited_once_with("👀", adapter._client.user)
|
||||
raw_message.add_reaction.assert_not_awaited()
|
||||
|
||||
@@ -62,6 +62,7 @@ def adapter():
|
||||
fetch_channel=AsyncMock(),
|
||||
user=SimpleNamespace(id=99999, name="HermesBot"),
|
||||
)
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
return adapter
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for gateway /fast support and Priority Processing routing."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class _CapturingAgent:
|
||||
last_init = None
|
||||
last_run = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
type(self).last_init = dict(kwargs)
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None):
|
||||
type(self).last_run = {
|
||||
"user_message": user_message,
|
||||
"conversation_history": conversation_history,
|
||||
"task_id": task_id,
|
||||
"persist_user_message": persist_user_message,
|
||||
}
|
||||
return {
|
||||
"final_response": "ok",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
"completed": True,
|
||||
}
|
||||
|
||||
|
||||
def _install_fake_agent(monkeypatch):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CapturingAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._prefill_messages = []
|
||||
runner._reasoning_config = None
|
||||
runner._service_tier = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._smart_model_routing = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._session_model_overrides = {}
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
||||
runner.config = SimpleNamespace(streaming=None)
|
||||
runner.session_store = SimpleNamespace(
|
||||
get_or_create_session=lambda source: SimpleNamespace(session_id="session-1"),
|
||||
load_transcript=lambda session_id: [],
|
||||
)
|
||||
runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
|
||||
runner._enrich_message_with_vision = AsyncMock(return_value="ENRICHED")
|
||||
return runner
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=_make_source(), message_id="m1")
|
||||
|
||||
|
||||
def test_turn_route_injects_priority_processing_without_changing_runtime():
|
||||
runner = _make_runner()
|
||||
runner._service_tier = "priority"
|
||||
runtime_kwargs = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.4",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.4", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.4", runtime_kwargs)
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route["runtime"]["api_mode"] == "chat_completions"
|
||||
assert route["request_overrides"] == {"service_tier": "priority"}
|
||||
|
||||
|
||||
def test_turn_route_skips_priority_processing_for_unsupported_models():
|
||||
runner = _make_runner()
|
||||
runner._service_tier = "priority"
|
||||
runtime_kwargs = {
|
||||
"api_key": "***",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": [],
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch("agent.smart_model_routing.resolve_turn_route", return_value={
|
||||
"model": "gpt-5.3-codex",
|
||||
"runtime": dict(runtime_kwargs),
|
||||
"label": None,
|
||||
"signature": ("gpt-5.3-codex", "openrouter", "https://openrouter.ai/api/v1", "chat_completions", None, ()),
|
||||
}):
|
||||
route = gateway_run.GatewayRunner._resolve_turn_agent_config(runner, "hi", "gpt-5.3-codex", runtime_kwargs)
|
||||
|
||||
assert route["request_overrides"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_fast_command_persists_config(monkeypatch, tmp_path):
|
||||
runner = _make_runner()
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
|
||||
response = await runner._handle_fast_command(_make_event("/fast fast"))
|
||||
|
||||
assert "FAST" in response
|
||||
assert runner._service_tier == "priority"
|
||||
|
||||
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||
assert saved["agent"]["service_tier"] == "fast"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch, tmp_path):
|
||||
_install_fake_agent(monkeypatch)
|
||||
runner = _make_runner()
|
||||
|
||||
(tmp_path / "config.yaml").write_text("agent:\n service_tier: fast\n", encoding="utf-8")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env")
|
||||
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_resolve_runtime_agent_kwargs",
|
||||
lambda: {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "***",
|
||||
},
|
||||
)
|
||||
|
||||
import hermes_cli.tools_config as tools_config
|
||||
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"})
|
||||
|
||||
_CapturingAgent.last_init = None
|
||||
result = await runner._run_agent(
|
||||
message="hi",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=_make_source(),
|
||||
session_id="session-1",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "ok"
|
||||
assert _CapturingAgent.last_init["service_tier"] == "priority"
|
||||
assert _CapturingAgent.last_init["request_overrides"] == {"service_tier": "priority"}
|
||||
@@ -128,12 +128,16 @@ async def test_internal_event_bypasses_authorization(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(GatewayRunner, "_is_user_authorized", tracking_auth)
|
||||
|
||||
# _handle_message will proceed past auth check and eventually fail on
|
||||
# downstream logic. We just need to verify auth is skipped.
|
||||
# Stop execution before the agent runner so the test doesn't block in
|
||||
# run_in_executor. Auth check happens before _handle_message_with_agent.
|
||||
async def _raise(*_a, **_kw):
|
||||
raise RuntimeError("sentinel — stop here")
|
||||
monkeypatch.setattr(GatewayRunner, "_handle_message_with_agent", _raise)
|
||||
|
||||
try:
|
||||
await runner._handle_message(event)
|
||||
except Exception:
|
||||
pass # Expected — downstream code needs more setup
|
||||
except RuntimeError:
|
||||
pass # Expected sentinel
|
||||
|
||||
assert not auth_called, (
|
||||
"_is_user_authorized should NOT be called for internal events"
|
||||
@@ -175,10 +179,16 @@ async def test_internal_event_does_not_trigger_pairing(monkeypatch, tmp_path):
|
||||
|
||||
runner.pairing_store.generate_code = tracking_generate
|
||||
|
||||
# Stop execution before the agent runner so the test doesn't block in
|
||||
# run_in_executor. Pairing check happens before _handle_message_with_agent.
|
||||
async def _raise(*_a, **_kw):
|
||||
raise RuntimeError("sentinel — stop here")
|
||||
monkeypatch.setattr(GatewayRunner, "_handle_message_with_agent", _raise)
|
||||
|
||||
try:
|
||||
await runner._handle_message(event)
|
||||
except Exception:
|
||||
pass # Expected — downstream code needs more setup
|
||||
except RuntimeError:
|
||||
pass # Expected sentinel
|
||||
|
||||
assert not generate_called, (
|
||||
"Pairing code should NOT be generated for internal events"
|
||||
|
||||
@@ -1943,7 +1943,7 @@ class TestMatrixReactions:
|
||||
|
||||
with patch.dict("sys.modules", {"nio": fake_nio}):
|
||||
result = await self.adapter._send_reaction("!room:ex", "$event1", "👍")
|
||||
assert result is True
|
||||
assert result == "$reaction1"
|
||||
mock_client.room_send.assert_called_once()
|
||||
args = mock_client.room_send.call_args
|
||||
assert args[0][1] == "m.reaction"
|
||||
@@ -1956,7 +1956,7 @@ class TestMatrixReactions:
|
||||
self.adapter._client = None
|
||||
with patch.dict("sys.modules", {"nio": _make_fake_nio()}):
|
||||
result = await self.adapter._send_reaction("!room:ex", "$ev", "👍")
|
||||
assert result is False
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_start_sends_eyes(self):
|
||||
@@ -1964,7 +1964,7 @@ class TestMatrixReactions:
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
|
||||
self.adapter._reactions_enabled = True
|
||||
self.adapter._send_reaction = AsyncMock(return_value=True)
|
||||
self.adapter._send_reaction = AsyncMock(return_value="$reaction_event_123")
|
||||
|
||||
source = MagicMock()
|
||||
source.chat_id = "!room:ex"
|
||||
@@ -1977,10 +1977,55 @@ class TestMatrixReactions:
|
||||
)
|
||||
await self.adapter.on_processing_start(event)
|
||||
self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "👀")
|
||||
assert self.adapter._pending_reactions == {("!room:ex", "$msg1"): "$reaction_event_123"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_sends_check(self):
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
|
||||
self.adapter._reactions_enabled = True
|
||||
self.adapter._pending_reactions = {("!room:ex", "$msg1"): "$eyes_reaction_123"}
|
||||
self.adapter._redact_reaction = AsyncMock(return_value=True)
|
||||
self.adapter._send_reaction = AsyncMock(return_value="$check_reaction_456")
|
||||
|
||||
source = MagicMock()
|
||||
source.chat_id = "!room:ex"
|
||||
event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message={},
|
||||
message_id="$msg1",
|
||||
)
|
||||
await self.adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
self.adapter._redact_reaction.assert_called_once_with("!room:ex", "$eyes_reaction_123")
|
||||
self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "✅")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_sends_cross_on_failure(self):
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
|
||||
self.adapter._reactions_enabled = True
|
||||
self.adapter._pending_reactions = {("!room:ex", "$msg1"): "$eyes_reaction_123"}
|
||||
self.adapter._redact_reaction = AsyncMock(return_value=True)
|
||||
self.adapter._send_reaction = AsyncMock(return_value="$cross_reaction_456")
|
||||
|
||||
source = MagicMock()
|
||||
source.chat_id = "!room:ex"
|
||||
event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message={},
|
||||
message_id="$msg1",
|
||||
)
|
||||
await self.adapter.on_processing_complete(event, ProcessingOutcome.FAILURE)
|
||||
self.adapter._redact_reaction.assert_called_once_with("!room:ex", "$eyes_reaction_123")
|
||||
self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "❌")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_cancelled_sends_no_terminal_reaction(self):
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
|
||||
self.adapter._reactions_enabled = True
|
||||
self.adapter._send_reaction = AsyncMock(return_value=True)
|
||||
@@ -1994,7 +2039,30 @@ class TestMatrixReactions:
|
||||
raw_message={},
|
||||
message_id="$msg1",
|
||||
)
|
||||
await self.adapter.on_processing_complete(event, success=True)
|
||||
await self.adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
|
||||
self.adapter._send_reaction.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_no_pending_reaction(self):
|
||||
"""on_processing_complete should skip redaction if no eyes reaction was tracked."""
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
|
||||
self.adapter._reactions_enabled = True
|
||||
self.adapter._pending_reactions = {}
|
||||
self.adapter._redact_reaction = AsyncMock()
|
||||
self.adapter._send_reaction = AsyncMock(return_value="$check_reaction_789")
|
||||
|
||||
source = MagicMock()
|
||||
source.chat_id = "!room:ex"
|
||||
event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message={},
|
||||
message_id="$msg1",
|
||||
)
|
||||
await self.adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
self.adapter._redact_reaction.assert_not_called()
|
||||
self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "✅")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -44,6 +44,7 @@ def _make_adapter(tmp_path=None):
|
||||
},
|
||||
)
|
||||
adapter = MatrixAdapter(config)
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._startup_ts = time.time() - 10 # avoid startup grace filter
|
||||
return adapter
|
||||
|
||||
@@ -34,6 +34,45 @@ def _make_timeout_error() -> httpx.TimeoutException:
|
||||
return httpx.TimeoutException("timed out")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cache_image_from_bytes (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheImageFromBytes:
|
||||
"""Tests for gateway.platforms.base.cache_image_from_bytes"""
|
||||
|
||||
def test_caches_valid_jpeg(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
path = cache_image_from_bytes(b"\xff\xd8\xff fake jpeg data", ".jpg")
|
||||
assert path.endswith(".jpg")
|
||||
|
||||
def test_caches_valid_png(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
path = cache_image_from_bytes(b"\x89PNG\r\n\x1a\n fake png data", ".png")
|
||||
assert path.endswith(".png")
|
||||
|
||||
def test_rejects_html_content(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
with pytest.raises(ValueError, match="non-image data"):
|
||||
cache_image_from_bytes(b"<!DOCTYPE html><html><title>Slack</title></html>", ".png")
|
||||
|
||||
def test_rejects_empty_data(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
with pytest.raises(ValueError, match="non-image data"):
|
||||
cache_image_from_bytes(b"", ".jpg")
|
||||
|
||||
def test_rejects_plain_text(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
with pytest.raises(ValueError, match="non-image data"):
|
||||
cache_image_from_bytes(b"just some text, not an image", ".jpg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cache_image_from_url (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -71,7 +110,7 @@ class TestCacheImageFromUrl:
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"image data"
|
||||
fake_response.content = b"\xff\xd8\xff image data"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
@@ -101,7 +140,7 @@ class TestCacheImageFromUrl:
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
ok_response = MagicMock()
|
||||
ok_response.content = b"image data"
|
||||
ok_response.content = b"\xff\xd8\xff image data"
|
||||
ok_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
@@ -337,6 +376,134 @@ class TestCacheAudioFromUrl:
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF redirect guard tests (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSSRFRedirectGuard:
|
||||
"""cache_image_from_url / cache_audio_from_url must reject redirects
|
||||
that land on private/internal hosts (e.g. cloud metadata endpoint)."""
|
||||
|
||||
def _make_redirect_response(self, target_url: str):
|
||||
"""Build a mock httpx response that looks like a redirect."""
|
||||
resp = MagicMock()
|
||||
resp.is_redirect = True
|
||||
resp.next_request = MagicMock(url=target_url)
|
||||
return resp
|
||||
|
||||
def _make_client_capturing_hooks(self):
|
||||
"""Return (mock_client, captured_kwargs dict) where captured_kwargs
|
||||
will contain the kwargs passed to httpx.AsyncClient()."""
|
||||
captured = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return mock_client
|
||||
|
||||
return mock_client, captured, factory
|
||||
|
||||
def test_image_blocks_private_redirect(self, tmp_path, monkeypatch):
|
||||
"""cache_image_from_url rejects a redirect to a private IP."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
redirect_resp = self._make_redirect_response(
|
||||
"http://169.254.169.254/latest/meta-data"
|
||||
)
|
||||
mock_client, captured, factory = self._make_client_capturing_hooks()
|
||||
|
||||
async def fake_get(_url, **kwargs):
|
||||
# Simulate httpx calling the response event hooks
|
||||
for hook in captured["event_hooks"]["response"]:
|
||||
await hook(redirect_resp)
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
def fake_safe(url):
|
||||
return url == "https://public.example.com/image.png"
|
||||
|
||||
async def run():
|
||||
with patch("tools.url_safety.is_safe_url", side_effect=fake_safe), \
|
||||
patch("httpx.AsyncClient", side_effect=factory):
|
||||
from gateway.platforms.base import cache_image_from_url
|
||||
await cache_image_from_url(
|
||||
"https://public.example.com/image.png", ext=".png"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Blocked redirect"):
|
||||
asyncio.run(run())
|
||||
|
||||
def test_audio_blocks_private_redirect(self, tmp_path, monkeypatch):
|
||||
"""cache_audio_from_url rejects a redirect to a private IP."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
redirect_resp = self._make_redirect_response(
|
||||
"http://10.0.0.1/internal/secrets"
|
||||
)
|
||||
mock_client, captured, factory = self._make_client_capturing_hooks()
|
||||
|
||||
async def fake_get(_url, **kwargs):
|
||||
for hook in captured["event_hooks"]["response"]:
|
||||
await hook(redirect_resp)
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
def fake_safe(url):
|
||||
return url == "https://public.example.com/voice.ogg"
|
||||
|
||||
async def run():
|
||||
with patch("tools.url_safety.is_safe_url", side_effect=fake_safe), \
|
||||
patch("httpx.AsyncClient", side_effect=factory):
|
||||
from gateway.platforms.base import cache_audio_from_url
|
||||
await cache_audio_from_url(
|
||||
"https://public.example.com/voice.ogg", ext=".ogg"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Blocked redirect"):
|
||||
asyncio.run(run())
|
||||
|
||||
def test_safe_redirect_allowed(self, tmp_path, monkeypatch):
|
||||
"""A redirect to a public IP is allowed through."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
redirect_resp = self._make_redirect_response(
|
||||
"https://cdn.example.com/real-image.png"
|
||||
)
|
||||
|
||||
ok_response = MagicMock()
|
||||
ok_response.content = b"\xff\xd8\xff fake jpeg"
|
||||
ok_response.raise_for_status = MagicMock()
|
||||
ok_response.is_redirect = False
|
||||
|
||||
mock_client, captured, factory = self._make_client_capturing_hooks()
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get(_url, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# First call triggers redirect hook, second returns data
|
||||
for hook in captured["event_hooks"]["response"]:
|
||||
await hook(redirect_resp if call_count == 1 else ok_response)
|
||||
return ok_response
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
async def run():
|
||||
with patch("tools.url_safety.is_safe_url", return_value=True), \
|
||||
patch("httpx.AsyncClient", side_effect=factory):
|
||||
from gateway.platforms.base import cache_image_from_url
|
||||
return await cache_image_from_url(
|
||||
"https://public.example.com/image.png", ext=".jpg"
|
||||
)
|
||||
|
||||
path = asyncio.run(run())
|
||||
assert path.endswith(".jpg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slack mock setup (mirrors existing test_slack.py approach)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -395,8 +562,9 @@ class TestSlackDownloadSlackFile:
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"fake image bytes"
|
||||
fake_response.content = b"\x89PNG\r\n\x1a\n fake png"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "image/png"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=fake_response)
|
||||
@@ -413,14 +581,44 @@ class TestSlackDownloadSlackFile:
|
||||
assert path.endswith(".jpg")
|
||||
mock_client.get.assert_called_once()
|
||||
|
||||
def test_rejects_html_response(self, tmp_path, monkeypatch):
|
||||
"""An HTML sign-in page from Slack is rejected, not cached as image."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"<!DOCTYPE html><html><title>Slack</title></html>"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "text/html; charset=utf-8"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=fake_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
async def run():
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
await adapter._download_slack_file(
|
||||
"https://files.slack.com/img.jpg", ext=".jpg"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="HTML instead of media"):
|
||||
asyncio.run(run())
|
||||
|
||||
# Verify nothing was cached
|
||||
img_dir = tmp_path / "img"
|
||||
if img_dir.exists():
|
||||
assert list(img_dir.iterdir()) == []
|
||||
|
||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
||||
"""Timeout on first attempt triggers retry; success on second."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
adapter = _make_slack_adapter()
|
||||
|
||||
fake_response = MagicMock()
|
||||
fake_response.content = b"image bytes"
|
||||
fake_response.content = b"\x89PNG\r\n\x1a\n image bytes"
|
||||
fake_response.raise_for_status = MagicMock()
|
||||
fake_response.headers = {"content-type": "image/png"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Regression tests for gateway /model support of config.yaml custom_providers."""
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._voice_mode = {}
|
||||
runner._session_model_overrides = {}
|
||||
return runner
|
||||
|
||||
|
||||
def _make_event(text="/model"):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_model_command_lists_saved_custom_provider(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"model": {
|
||||
"default": "gpt-5.4",
|
||||
"provider": "openai-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
},
|
||||
"providers": {},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "Local (127.0.0.1:4141)",
|
||||
"base_url": "http://127.0.0.1:4141/v1",
|
||||
"model": "rotator-openrouter-coding",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import gateway.run as gateway_run
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
|
||||
result = await _make_runner()._handle_model_command(_make_event())
|
||||
|
||||
assert result is not None
|
||||
assert "Local (127.0.0.1:4141)" in result
|
||||
assert "custom:local-(127.0.0.1:4141)" in result
|
||||
assert "rotator-openrouter-coding" in result
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests that gateway /model switch persists across messages.
|
||||
|
||||
The gateway /model command stores session overrides in
|
||||
``_session_model_overrides``. These must:
|
||||
|
||||
1. Be applied in ``run_sync()`` so the next agent uses the switched model.
|
||||
2. Not be mistaken for fallback activation (which evicts the cached agent).
|
||||
3. Survive across multiple messages until /reset clears them.
|
||||
|
||||
Tests exercise the real ``_apply_session_model_override()`` and
|
||||
``_is_intentional_model_switch()`` methods on ``GatewayRunner``.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="u1",
|
||||
chat_id="c1",
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Create a minimal GatewayRunner with stubbed internals."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="tok")}
|
||||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
runner._session_model_overrides = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._background_tasks = set()
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._session_db = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = None
|
||||
runner._effective_model = None
|
||||
runner._effective_provider = None
|
||||
runner.session_store = MagicMock()
|
||||
session_key = build_session_key(_make_source())
|
||||
session_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store._entries = {session_key: session_entry}
|
||||
return runner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _apply_session_model_override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApplySessionModelOverride:
|
||||
"""Verify _apply_session_model_override replaces config defaults."""
|
||||
|
||||
def test_override_replaces_all_fields(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4-turbo",
|
||||
"provider": "openrouter",
|
||||
"api_key": "or-key-123",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
model, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert model == "gpt-5.4-turbo"
|
||||
assert rt["provider"] == "openrouter"
|
||||
assert rt["api_key"] == "or-key-123"
|
||||
assert rt["base_url"] == "https://openrouter.ai/api/v1"
|
||||
assert rt["api_mode"] == "chat_completions"
|
||||
|
||||
def test_no_override_returns_originals(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
orig_model = "anthropic/claude-sonnet-4"
|
||||
orig_rt = {"provider": "anthropic", "api_key": "key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"}
|
||||
|
||||
model, rt = runner._apply_session_model_override(sk, orig_model, dict(orig_rt))
|
||||
|
||||
assert model == orig_model
|
||||
assert rt == orig_rt
|
||||
|
||||
def test_none_values_do_not_overwrite(self):
|
||||
"""Override with None api_key/base_url should preserve config defaults."""
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": None,
|
||||
"base_url": None,
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
model, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert model == "gpt-5.4"
|
||||
assert rt["provider"] == "openai"
|
||||
assert rt["api_key"] == "ant-key" # preserved — None didn't overwrite
|
||||
assert rt["base_url"] == "https://api.anthropic.com" # preserved
|
||||
assert rt["api_mode"] == "chat_completions" # overwritten (not None)
|
||||
|
||||
def test_empty_string_overwrites(self):
|
||||
"""Empty string is not None — it should overwrite the config value."""
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "local-model",
|
||||
"provider": "custom",
|
||||
"api_key": "local-key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
_, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "https://api.anthropic.com", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert rt["base_url"] == "" # empty string overwrites
|
||||
|
||||
def test_different_session_key_not_affected(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
other_sk = "other_session"
|
||||
|
||||
runner._session_model_overrides[other_sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
model, rt = runner._apply_session_model_override(
|
||||
sk,
|
||||
"anthropic/claude-sonnet-4",
|
||||
{"provider": "anthropic", "api_key": "ant-key", "base_url": "url", "api_mode": "anthropic_messages"},
|
||||
)
|
||||
|
||||
assert model == "anthropic/claude-sonnet-4" # unchanged — wrong session key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _is_intentional_model_switch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsIntentionalModelSwitch:
|
||||
"""Verify fallback detection respects intentional /model overrides."""
|
||||
|
||||
def test_matches_override(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4") is True
|
||||
|
||||
def test_no_override_returns_false(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4") is False
|
||||
|
||||
def test_different_model_returns_false(self):
|
||||
"""Agent fell back to a different model than the override."""
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides[sk] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4-mini") is False
|
||||
|
||||
def test_wrong_session_key(self):
|
||||
runner = _make_runner()
|
||||
sk = build_session_key(_make_source())
|
||||
|
||||
runner._session_model_overrides["other_session"] = {
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai",
|
||||
"api_key": "key",
|
||||
"base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
assert runner._is_intentional_model_switch(sk, "gpt-5.4") is False
|
||||
@@ -8,7 +8,7 @@ from gateway.platforms.base import (
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
_safe_url_for_log,
|
||||
safe_url_for_log,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestSafeUrlForLog:
|
||||
"https://user:pass@example.com/private/path/image.png"
|
||||
"?X-Amz-Signature=supersecret&token=abc#frag"
|
||||
)
|
||||
result = _safe_url_for_log(url)
|
||||
result = safe_url_for_log(url)
|
||||
assert result == "https://example.com/.../image.png"
|
||||
assert "supersecret" not in result
|
||||
assert "token=abc" not in result
|
||||
@@ -33,15 +33,15 @@ class TestSafeUrlForLog:
|
||||
|
||||
def test_truncates_long_values(self):
|
||||
long_url = "https://example.com/" + ("a" * 300)
|
||||
result = _safe_url_for_log(long_url, max_len=40)
|
||||
result = safe_url_for_log(long_url, max_len=40)
|
||||
assert len(result) == 40
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_handles_small_and_non_positive_max_len(self):
|
||||
url = "https://example.com/very/long/path/file.png?token=secret"
|
||||
assert _safe_url_for_log(url, max_len=3) == "..."
|
||||
assert _safe_url_for_log(url, max_len=2) == ".."
|
||||
assert _safe_url_for_log(url, max_len=0) == ""
|
||||
assert safe_url_for_log(url, max_len=3) == "..."
|
||||
assert safe_url_for_log(url, max_len=2) == ".."
|
||||
assert safe_url_for_log(url, max_len=0) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -144,7 +144,7 @@ async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_pa
|
||||
assert adapter.sent == [
|
||||
{
|
||||
"chat_id": "-1001",
|
||||
"content": '💻 terminal: "pwd"',
|
||||
"content": '⚙️ terminal: "pwd"',
|
||||
"reply_to": None,
|
||||
"metadata": {"thread_id": "17585"},
|
||||
}
|
||||
|
||||
@@ -87,3 +87,42 @@ async def test_runner_allows_cron_only_mode_when_no_platforms_are_enabled(monkey
|
||||
assert runner.adapters == {}
|
||||
state = read_runtime_status()
|
||||
assert state["gateway_state"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_gateway_replace_force_uses_terminate_pid(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
calls = []
|
||||
|
||||
class _CleanExitRunner:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.should_exit_cleanly = True
|
||||
self.exit_reason = None
|
||||
self.adapters = {}
|
||||
|
||||
async def start(self):
|
||||
return True
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 42)
|
||||
monkeypatch.setattr("gateway.status.remove_pid_file", lambda: None)
|
||||
monkeypatch.setattr("gateway.status.release_all_scoped_locks", lambda: 0)
|
||||
monkeypatch.setattr("gateway.status.terminate_pid", lambda pid, force=False: calls.append((pid, force)))
|
||||
monkeypatch.setattr("gateway.run.os.getpid", lambda: 100)
|
||||
monkeypatch.setattr("gateway.run.os.kill", lambda pid, sig: None)
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
monkeypatch.setattr("tools.skills_sync.sync_skills", lambda quiet=True: None)
|
||||
monkeypatch.setattr("hermes_logging.setup_logging", lambda hermes_home, mode: tmp_path)
|
||||
monkeypatch.setattr("hermes_logging._add_rotating_handler", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("gateway.run.GatewayRunner", _CleanExitRunner)
|
||||
|
||||
from gateway.run import start_gateway
|
||||
|
||||
ok = await start_gateway(config=GatewayConfig(), replace=True, verbosity=None)
|
||||
|
||||
assert ok is True
|
||||
assert calls == [(42, False), (42, True)]
|
||||
|
||||
@@ -90,7 +90,10 @@ class TestSessionSourceRoundtrip:
|
||||
|
||||
class TestSessionSourceDescription:
|
||||
def test_local_cli(self):
|
||||
source = SessionSource.local_cli()
|
||||
source = SessionSource(
|
||||
platform=Platform.LOCAL, chat_id="cli",
|
||||
chat_name="CLI terminal", chat_type="dm",
|
||||
)
|
||||
assert source.description == "CLI terminal"
|
||||
|
||||
def test_dm_with_username(self):
|
||||
@@ -143,7 +146,10 @@ class TestSessionSourceDescription:
|
||||
|
||||
class TestLocalCliFactory:
|
||||
def test_local_cli_defaults(self):
|
||||
source = SessionSource.local_cli()
|
||||
source = SessionSource(
|
||||
platform=Platform.LOCAL, chat_id="cli",
|
||||
chat_name="CLI terminal", chat_type="dm",
|
||||
)
|
||||
assert source.platform == Platform.LOCAL
|
||||
assert source.chat_id == "cli"
|
||||
assert source.chat_type == "dm"
|
||||
@@ -267,7 +273,10 @@ class TestBuildSessionContextPrompt:
|
||||
|
||||
def test_local_prompt_mentions_machine(self):
|
||||
config = GatewayConfig()
|
||||
source = SessionSource.local_cli()
|
||||
source = SessionSource(
|
||||
platform=Platform.LOCAL, chat_id="cli",
|
||||
chat_name="CLI terminal", chat_type="dm",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
"""Tests for DM thread session seeding.
|
||||
"""Tests for DM thread session isolation.
|
||||
|
||||
When a bot reply creates a thread in a DM (e.g. Slack), the user's reply
|
||||
in that thread gets a new session (keyed by thread_ts). The seeding logic
|
||||
copies the parent DM session's transcript into the new thread session so
|
||||
the bot retains context of the original conversation.
|
||||
DM thread sessions must start empty — no parent transcript seeding.
|
||||
Thread context is handled by platform adapters (e.g. Slack's
|
||||
_fetch_thread_context fetches actual thread replies via the API).
|
||||
Session-level seeding was removed because it copied the ENTIRE parent
|
||||
DM transcript, causing unrelated conversations to bleed across threads.
|
||||
|
||||
Covers:
|
||||
- Basic seeding: parent transcript copied to new thread session
|
||||
- No seeding for group/channel chats
|
||||
- No seeding when parent session doesn't exist
|
||||
- No seeding on auto-reset sessions
|
||||
- No seeding on existing (non-new) thread sessions
|
||||
- Parent transcript is not mutated by seeding
|
||||
- Multiple threads from same parent each get independent copies
|
||||
- Cross-platform: works for any platform with DM threads (Slack, Telegram, Discord)
|
||||
- Thread sessions start empty (no parent seeding)
|
||||
- Group/channel thread sessions also start empty
|
||||
- Multiple threads from same parent are independent
|
||||
- Existing thread sessions are not mutated on re-access
|
||||
- Cross-platform: consistent behavior for Slack, Telegram, Discord
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -60,48 +58,41 @@ PARENT_HISTORY = [
|
||||
]
|
||||
|
||||
|
||||
class TestDMThreadSeeding:
|
||||
"""Core seeding behavior."""
|
||||
class TestDMThreadIsolation:
|
||||
"""Thread sessions must start empty — no parent transcript seeding."""
|
||||
|
||||
def test_thread_session_seeded_from_parent(self, store):
|
||||
"""New DM thread session should contain the parent's transcript."""
|
||||
# Create parent DM session with history
|
||||
def test_thread_session_starts_empty(self, store):
|
||||
"""New DM thread session should NOT inherit parent's transcript."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
store.append_to_transcript(parent_entry.session_id, msg)
|
||||
|
||||
# Create thread session (user replied in thread)
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
|
||||
# Thread should have parent's history
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 2
|
||||
assert thread_transcript[0]["content"] == "What's the weather?"
|
||||
assert thread_transcript[1]["content"] == "It's sunny and 72°F."
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_parent_transcript_not_mutated(self, store):
|
||||
"""Seeding should not alter the parent session's transcript."""
|
||||
def test_parent_transcript_unaffected_by_thread(self, store):
|
||||
"""Creating a thread session should not alter parent's transcript."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
store.append_to_transcript(parent_entry.session_id, msg)
|
||||
|
||||
# Create thread and add a message to it
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
store.append_to_transcript(thread_entry.session_id, {
|
||||
"role": "user", "content": "thread-only message"
|
||||
})
|
||||
|
||||
# Parent should still have only its original messages
|
||||
parent_transcript = store.load_transcript(parent_entry.session_id)
|
||||
assert len(parent_transcript) == 2
|
||||
assert all(m["content"] != "thread-only message" for m in parent_transcript)
|
||||
|
||||
def test_multiple_threads_get_independent_copies(self, store):
|
||||
"""Each thread from the same parent gets its own copy."""
|
||||
def test_multiple_threads_are_independent(self, store):
|
||||
"""Each thread from the same parent starts empty and stays independent."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
@@ -118,49 +109,43 @@ class TestDMThreadSeeding:
|
||||
thread_b_source = _dm_source(thread_id="2222.000002")
|
||||
thread_b_entry = store.get_or_create_session(thread_b_source)
|
||||
|
||||
# Thread B should have parent history, not thread A's additions
|
||||
# Thread B starts empty
|
||||
thread_b_transcript = store.load_transcript(thread_b_entry.session_id)
|
||||
assert len(thread_b_transcript) == 2
|
||||
assert all(m["content"] != "thread A message" for m in thread_b_transcript)
|
||||
assert len(thread_b_transcript) == 0
|
||||
|
||||
# Thread A should have parent history + its own message
|
||||
# Thread A has only its own message
|
||||
thread_a_transcript = store.load_transcript(thread_a_entry.session_id)
|
||||
assert len(thread_a_transcript) == 3
|
||||
assert len(thread_a_transcript) == 1
|
||||
assert thread_a_transcript[0]["content"] == "thread A message"
|
||||
|
||||
def test_existing_thread_session_not_reseeded(self, store):
|
||||
"""Returning to an existing thread session should not re-copy parent history."""
|
||||
def test_existing_thread_session_preserved(self, store):
|
||||
"""Returning to an existing thread session should not reset it."""
|
||||
parent_source = _dm_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
store.append_to_transcript(parent_entry.session_id, msg)
|
||||
|
||||
# Create thread session
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
store.append_to_transcript(thread_entry.session_id, {
|
||||
"role": "user", "content": "follow-up"
|
||||
})
|
||||
|
||||
# Add more to parent after thread was created
|
||||
store.append_to_transcript(parent_entry.session_id, {
|
||||
"role": "user", "content": "new parent message"
|
||||
})
|
||||
|
||||
# Get the same thread session again (not new — created_at != updated_at)
|
||||
# Get the same thread session again
|
||||
thread_entry_again = store.get_or_create_session(thread_source)
|
||||
assert thread_entry_again.session_id == thread_entry.session_id
|
||||
|
||||
# Should still have 3 messages (2 seeded + 1 follow-up), not re-seeded
|
||||
# Should still have only its own message
|
||||
thread_transcript = store.load_transcript(thread_entry_again.session_id)
|
||||
assert len(thread_transcript) == 3
|
||||
assert thread_transcript[2]["content"] == "follow-up"
|
||||
assert len(thread_transcript) == 1
|
||||
assert thread_transcript[0]["content"] == "follow-up"
|
||||
|
||||
|
||||
class TestDMThreadSeedingEdgeCases:
|
||||
"""Edge cases and conditions where seeding should NOT happen."""
|
||||
class TestDMThreadIsolationEdgeCases:
|
||||
"""Edge cases — threads always start empty regardless of context."""
|
||||
|
||||
def test_no_seeding_for_group_threads(self, store):
|
||||
"""Group/channel threads should not trigger seeding."""
|
||||
def test_group_thread_starts_empty(self, store):
|
||||
"""Group/channel threads should also start empty."""
|
||||
parent_source = _group_source()
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
@@ -172,7 +157,7 @@ class TestDMThreadSeedingEdgeCases:
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_no_seeding_without_parent_session(self, store):
|
||||
def test_thread_without_parent_session_starts_empty(self, store):
|
||||
"""Thread session without a parent DM session should start empty."""
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
@@ -180,34 +165,21 @@ class TestDMThreadSeedingEdgeCases:
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_no_seeding_with_empty_parent(self, store):
|
||||
"""If parent session exists but has no transcript, thread starts empty."""
|
||||
parent_source = _dm_source()
|
||||
store.get_or_create_session(parent_source)
|
||||
# No messages appended to parent
|
||||
|
||||
thread_source = _dm_source(thread_id="1234567890.000001")
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
def test_no_seeding_for_dm_without_thread_id(self, store):
|
||||
"""Top-level DMs (no thread_id) should not trigger seeding."""
|
||||
def test_dm_without_thread_starts_empty(self, store):
|
||||
"""Top-level DMs (no thread_id) should start empty as always."""
|
||||
source = _dm_source()
|
||||
entry = store.get_or_create_session(source)
|
||||
|
||||
# Should just be a normal empty session
|
||||
transcript = store.load_transcript(entry.session_id)
|
||||
assert len(transcript) == 0
|
||||
|
||||
|
||||
class TestDMThreadSeedingCrossPlatform:
|
||||
"""Verify seeding works for platforms beyond Slack."""
|
||||
class TestDMThreadIsolationCrossPlatform:
|
||||
"""Verify thread isolation is consistent across all platforms."""
|
||||
|
||||
@pytest.mark.parametrize("platform", [Platform.SLACK, Platform.TELEGRAM, Platform.DISCORD])
|
||||
def test_seeding_works_across_platforms(self, store, platform):
|
||||
"""DM thread seeding should work for any platform that uses thread_id."""
|
||||
def test_thread_starts_empty_across_platforms(self, store, platform):
|
||||
"""DM thread sessions start empty regardless of platform."""
|
||||
parent_source = _dm_source(platform=platform)
|
||||
parent_entry = store.get_or_create_session(parent_source)
|
||||
for msg in PARENT_HISTORY:
|
||||
@@ -217,5 +189,4 @@ class TestDMThreadSeedingCrossPlatform:
|
||||
thread_entry = store.get_or_create_session(thread_source)
|
||||
|
||||
thread_transcript = store.load_transcript(thread_entry.session_id)
|
||||
assert len(thread_transcript) == 2
|
||||
assert thread_transcript[0]["content"] == "What's the weather?"
|
||||
assert len(thread_transcript) == 0
|
||||
|
||||
@@ -1586,6 +1586,61 @@ class TestFallbackPreservesThreadContext:
|
||||
assert "important screenshot" in call_kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSendImageSSRFGuards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSendImageSSRFGuards:
|
||||
"""send_image should reject redirects that land on private/internal hosts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_blocks_private_redirect_target(self, adapter):
|
||||
redirect_response = MagicMock()
|
||||
redirect_response.is_redirect = True
|
||||
redirect_response.next_request = MagicMock(
|
||||
url="http://169.254.169.254/latest/meta-data"
|
||||
)
|
||||
|
||||
client_kwargs = {}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
async def fake_get(_url):
|
||||
for hook in client_kwargs["event_hooks"]["response"]:
|
||||
await hook(redirect_response)
|
||||
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True})
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"})
|
||||
|
||||
def fake_async_client(*args, **kwargs):
|
||||
client_kwargs.update(kwargs)
|
||||
return mock_client
|
||||
|
||||
def fake_is_safe_url(url):
|
||||
return url == "https://public.example/image.png"
|
||||
|
||||
with (
|
||||
patch("tools.url_safety.is_safe_url", side_effect=fake_is_safe_url),
|
||||
patch("httpx.AsyncClient", side_effect=fake_async_client),
|
||||
):
|
||||
result = await adapter.send_image(
|
||||
chat_id="C123",
|
||||
image_url="https://public.example/image.png",
|
||||
caption="see this",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert client_kwargs["follow_redirects"] is True
|
||||
assert client_kwargs["event_hooks"]["response"]
|
||||
adapter._app.client.files_upload_v2.assert_not_awaited()
|
||||
adapter._app.client.chat_postMessage.assert_awaited_once()
|
||||
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "see this" in call_kwargs["text"]
|
||||
assert "https://public.example/image.png" in call_kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestProgressMessageThread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gateway import status
|
||||
|
||||
@@ -104,6 +105,41 @@ class TestGatewayRuntimeStatus:
|
||||
assert payload["platforms"]["telegram"]["error_message"] == "another poller is active"
|
||||
|
||||
|
||||
class TestTerminatePid:
|
||||
def test_force_uses_taskkill_on_windows(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(status, "_IS_WINDOWS", True)
|
||||
|
||||
def fake_run(cmd, capture_output=False, text=False, timeout=None):
|
||||
calls.append((cmd, capture_output, text, timeout))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(status.subprocess, "run", fake_run)
|
||||
|
||||
status.terminate_pid(123, force=True)
|
||||
|
||||
assert calls == [
|
||||
(["taskkill", "/PID", "123", "/T", "/F"], True, True, 10)
|
||||
]
|
||||
|
||||
def test_force_falls_back_to_sigterm_when_taskkill_missing(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(status, "_IS_WINDOWS", True)
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
raise FileNotFoundError
|
||||
|
||||
def fake_kill(pid, sig):
|
||||
calls.append((pid, sig))
|
||||
|
||||
monkeypatch.setattr(status.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(status.os, "kill", fake_kill)
|
||||
|
||||
status.terminate_pid(456, force=True)
|
||||
|
||||
assert calls == [(456, status.signal.SIGTERM)]
|
||||
|
||||
|
||||
class TestScopedLocks:
|
||||
def test_acquire_scoped_lock_rejects_live_other_process(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
|
||||
|
||||
@@ -437,6 +437,45 @@ class TestSegmentBreakOnToolBoundary:
|
||||
# Only one send call (the initial message)
|
||||
assert adapter.send.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_message_id_segment_breaks_do_not_resend(self):
|
||||
"""On a platform that never returns a message_id (e.g. webhook with
|
||||
github_comment delivery), tool-call segment breaks must NOT trigger
|
||||
a new adapter.send() per boundary. The fix: _message_id == '__no_edit__'
|
||||
suppresses the reset so all text accumulates and is sent once."""
|
||||
adapter = MagicMock()
|
||||
# No message_id on first send, then one more for the fallback final
|
||||
adapter.send = AsyncMock(side_effect=[
|
||||
SimpleNamespace(success=True, message_id=None),
|
||||
SimpleNamespace(success=True, message_id=None),
|
||||
])
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Simulate: text → tool boundary → text → tool boundary → text (3 segments)
|
||||
consumer.on_delta("Phase 1 text")
|
||||
consumer.on_delta(None) # tool call boundary
|
||||
consumer.on_delta("Phase 2 text")
|
||||
consumer.on_delta(None) # another tool call boundary
|
||||
consumer.on_delta("Phase 3 text")
|
||||
consumer.finish()
|
||||
|
||||
await consumer.run()
|
||||
|
||||
# Before the fix this would post 3 comments (one per segment).
|
||||
# After the fix: only the initial partial + one fallback-final continuation.
|
||||
assert adapter.send.call_count == 2, (
|
||||
f"Expected 2 sends (initial + fallback), got {adapter.send.call_count}"
|
||||
)
|
||||
assert consumer.already_sent
|
||||
# The continuation must contain the text from segments 2 and 3
|
||||
final_text = adapter.send.call_args_list[1][1]["content"]
|
||||
assert "Phase 2" in final_text
|
||||
assert "Phase 3" in final_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_splits_long_continuation_without_dropping_text(self):
|
||||
"""Long continuation tails should be chunked when fallback final-send runs."""
|
||||
|
||||
@@ -40,9 +40,6 @@ async def test_enrich_message_with_transcription_skips_when_stt_disabled():
|
||||
with patch(
|
||||
"tools.transcription_tools.transcribe_audio",
|
||||
side_effect=AssertionError("transcribe_audio should not be called when STT is disabled"),
|
||||
), patch(
|
||||
"tools.transcription_tools.get_stt_model_from_config",
|
||||
return_value=None,
|
||||
):
|
||||
result = await runner._enrich_message_with_transcription(
|
||||
"caption",
|
||||
@@ -63,9 +60,6 @@ async def test_enrich_message_with_transcription_avoids_bogus_no_provider_messag
|
||||
with patch(
|
||||
"tools.transcription_tools.transcribe_audio",
|
||||
return_value={"success": False, "error": "VOICE_TOOLS_OPENAI_KEY not set"},
|
||||
), patch(
|
||||
"tools.transcription_tools.get_stt_model_from_config",
|
||||
return_value=None,
|
||||
):
|
||||
result = await runner._enrich_message_with_transcription(
|
||||
"caption",
|
||||
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
@@ -175,33 +175,33 @@ async def test_on_processing_start_handles_missing_ids(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_success(monkeypatch):
|
||||
"""Successful processing should set check mark reaction."""
|
||||
"""Successful processing should set thumbs-up reaction."""
|
||||
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, success=True)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||
chat_id=123,
|
||||
message_id=456,
|
||||
reaction="\u2705",
|
||||
reaction="\U0001f44d",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_failure(monkeypatch):
|
||||
"""Failed processing should set cross mark reaction."""
|
||||
"""Failed processing should set thumbs-down reaction."""
|
||||
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, success=False)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.FAILURE)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_awaited_once_with(
|
||||
chat_id=123,
|
||||
message_id=456,
|
||||
reaction="\u274c",
|
||||
reaction="\U0001f44e",
|
||||
)
|
||||
|
||||
|
||||
@@ -212,7 +212,19 @@ async def test_on_processing_complete_skipped_when_disabled(monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, success=True)
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_processing_complete_cancelled_keeps_existing_reaction(monkeypatch):
|
||||
"""Expected cancellation should not replace the in-progress reaction."""
|
||||
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||
adapter = _make_adapter()
|
||||
event = _make_event()
|
||||
|
||||
await adapter.on_processing_complete(event, ProcessingOutcome.CANCELLED)
|
||||
|
||||
adapter._bot.set_message_reaction.assert_not_awaited()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user