Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a239b664e6 | |||
| 33c3f0a203 | |||
| f28390d3c8 |
+1
-2
@@ -13,8 +13,7 @@ COPY . /opt/hermes
|
||||
WORKDIR /opt/hermes
|
||||
|
||||
# Install Python and Node dependencies in one layer, no cache
|
||||
RUN pip install --no-cache-dir uv --break-system-packages && \
|
||||
uv pip install --system --break-system-packages --no-cache -e ".[all]" && \
|
||||
RUN pip install --no-cache-dir -e ".[all]" --break-system-packages && \
|
||||
npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
cd /opt/hermes/scripts/whatsapp-bridge && \
|
||||
|
||||
@@ -33,10 +33,8 @@ 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, WSL2, and Android via Termux. The installer handles the platform-specific setup for you.
|
||||
Works on Linux, macOS, and WSL2. The installer handles everything — Python, Node.js, dependencies, and the `hermes` command. No prerequisites except git.
|
||||
|
||||
> **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:
|
||||
|
||||
@@ -74,11 +74,8 @@ 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().replace(".", "-")
|
||||
m = model.lower()
|
||||
best_key = ""
|
||||
best_val = _ANTHROPIC_DEFAULT_OUTPUT_LIMIT
|
||||
for key, val in _ANTHROPIC_OUTPUT_LIMITS.items():
|
||||
@@ -98,15 +95,6 @@ _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.
|
||||
@@ -216,19 +204,6 @@ 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.
|
||||
|
||||
@@ -247,7 +222,6 @@ 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
|
||||
@@ -257,21 +231,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),
|
||||
@@ -281,8 +255,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)
|
||||
|
||||
@@ -1261,7 +1235,6 @@ 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().
|
||||
|
||||
@@ -1295,10 +1268,6 @@ 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 []
|
||||
@@ -1397,20 +1366,6 @@ 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
|
||||
|
||||
|
||||
@@ -1472,4 +1427,4 @@ def normalize_anthropic_response(
|
||||
reasoning_details=reasoning_details or None,
|
||||
),
|
||||
finish_reason,
|
||||
)
|
||||
)
|
||||
@@ -702,7 +702,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.30.0"}
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
elif "api.githubcopilot.com" in base_url.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
@@ -721,7 +721,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.30.0"}
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
elif "api.githubcopilot.com" in base_url.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
@@ -1137,7 +1137,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
main_model = _read_main_model()
|
||||
if (main_provider and main_model
|
||||
and main_provider not in _AGGREGATOR_PROVIDERS
|
||||
and main_provider not in ("auto", "")):
|
||||
and main_provider not in ("auto", "custom", "")):
|
||||
client, resolved = resolve_provider_client(main_provider, main_model)
|
||||
if client is not None:
|
||||
logger.info("Auxiliary auto-detect: using main provider %s (%s)",
|
||||
@@ -1195,7 +1195,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.30.0"}
|
||||
async_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
return AsyncOpenAI(**async_kwargs), model
|
||||
|
||||
|
||||
@@ -1317,7 +1317,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.30.0"}
|
||||
extra["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
elif "api.githubcopilot.com" in custom_base.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
@@ -1400,7 +1400,7 @@ def resolve_provider_client(
|
||||
# Provider-specific headers
|
||||
headers = {}
|
||||
if "api.kimi.com" in base_url.lower():
|
||||
headers["User-Agent"] = "KimiCLI/1.30.0"
|
||||
headers["User-Agent"] = "KimiCLI/1.3"
|
||||
elif "api.githubcopilot.com" in base_url.lower():
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
|
||||
+25
-35
@@ -691,43 +691,33 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
)
|
||||
compressed.append(msg)
|
||||
|
||||
# If LLM summary failed, insert a static fallback so the model
|
||||
# knows context was lost rather than silently dropping everything.
|
||||
if not summary:
|
||||
if not self.quiet_mode:
|
||||
logger.warning("Summary generation failed — inserting static fallback context marker")
|
||||
n_dropped = compress_end - compress_start
|
||||
summary = (
|
||||
f"{SUMMARY_PREFIX}\n"
|
||||
f"Summary generation was unavailable. {n_dropped} conversation turns were "
|
||||
f"removed to free context space but could not be summarized. The removed "
|
||||
f"turns contained earlier work in this session. Continue based on the "
|
||||
f"recent messages below and the current state of any files or resources."
|
||||
)
|
||||
|
||||
_merge_summary_into_tail = False
|
||||
last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user"
|
||||
first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user"
|
||||
# Pick a role that avoids consecutive same-role with both neighbors.
|
||||
# Priority: avoid colliding with head (already committed), then tail.
|
||||
if last_head_role in ("assistant", "tool"):
|
||||
summary_role = "user"
|
||||
else:
|
||||
summary_role = "assistant"
|
||||
# If the chosen role collides with the tail AND flipping wouldn't
|
||||
# collide with the head, flip it.
|
||||
if summary_role == first_tail_role:
|
||||
flipped = "assistant" if summary_role == "user" else "user"
|
||||
if flipped != last_head_role:
|
||||
summary_role = flipped
|
||||
if summary:
|
||||
last_head_role = messages[compress_start - 1].get("role", "user") if compress_start > 0 else "user"
|
||||
first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user"
|
||||
# Pick a role that avoids consecutive same-role with both neighbors.
|
||||
# Priority: avoid colliding with head (already committed), then tail.
|
||||
if last_head_role in ("assistant", "tool"):
|
||||
summary_role = "user"
|
||||
else:
|
||||
# Both roles would create consecutive same-role messages
|
||||
# (e.g. head=assistant, tail=user — neither role works).
|
||||
# Merge the summary into the first tail message instead
|
||||
# of inserting a standalone message that breaks alternation.
|
||||
_merge_summary_into_tail = True
|
||||
if not _merge_summary_into_tail:
|
||||
compressed.append({"role": summary_role, "content": summary})
|
||||
summary_role = "assistant"
|
||||
# If the chosen role collides with the tail AND flipping wouldn't
|
||||
# collide with the head, flip it.
|
||||
if summary_role == first_tail_role:
|
||||
flipped = "assistant" if summary_role == "user" else "user"
|
||||
if flipped != last_head_role:
|
||||
summary_role = flipped
|
||||
else:
|
||||
# Both roles would create consecutive same-role messages
|
||||
# (e.g. head=assistant, tail=user — neither role works).
|
||||
# Merge the summary into the first tail message instead
|
||||
# of inserting a standalone message that breaks alternation.
|
||||
_merge_summary_into_tail = True
|
||||
if not _merge_summary_into_tail:
|
||||
compressed.append({"role": summary_role, "content": summary})
|
||||
else:
|
||||
if not self.quiet_mode:
|
||||
logger.debug("No summary model available — middle turns dropped without summary")
|
||||
|
||||
for i in range(compress_end, n_messages):
|
||||
msg = messages[i].copy()
|
||||
|
||||
@@ -20,7 +20,6 @@ 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,
|
||||
@@ -28,8 +27,6 @@ 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,
|
||||
)
|
||||
@@ -482,67 +479,6 @@ 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:
|
||||
@@ -577,13 +513,6 @@ 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,
|
||||
@@ -669,37 +598,6 @@ 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
|
||||
|
||||
@@ -714,10 +612,6 @@ 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:
|
||||
|
||||
@@ -677,27 +677,6 @@ 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(
|
||||
|
||||
+2
-2
@@ -1158,7 +1158,7 @@ def main(
|
||||
providers_order (str): Comma-separated list of OpenRouter providers to try in order (e.g. "anthropic,openai,google")
|
||||
provider_sort (str): Sort providers by "price", "throughput", or "latency" (OpenRouter only)
|
||||
max_tokens (int): Maximum tokens for model responses (optional, uses model default if not set)
|
||||
reasoning_effort (str): OpenRouter reasoning effort level: "none", "minimal", "low", "medium", "high", "xhigh" (default: "medium")
|
||||
reasoning_effort (str): OpenRouter reasoning effort level: "xhigh", "high", "medium", "low", "minimal", "none" (default: "medium")
|
||||
reasoning_disabled (bool): Completely disable reasoning/thinking tokens (default: False)
|
||||
prefill_messages_file (str): Path to JSON file containing prefill messages (list of {role, content} dicts)
|
||||
max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set)
|
||||
@@ -1227,7 +1227,7 @@ def main(
|
||||
print("🧠 Reasoning: DISABLED (effort=none)")
|
||||
elif reasoning_effort:
|
||||
# Use specified effort level
|
||||
valid_efforts = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
valid_efforts = ["xhigh", "high", "medium", "low", "minimal", "none"]
|
||||
if reasoning_effort not in valid_efforts:
|
||||
print(f"❌ Error: --reasoning_effort must be one of: {', '.join(valid_efforts)}")
|
||||
return
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
+3
-60
@@ -346,42 +346,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
return None
|
||||
|
||||
|
||||
_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
|
||||
_SCRIPT_TIMEOUT = 120 # seconds
|
||||
|
||||
|
||||
def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
@@ -428,14 +393,12 @@ 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()
|
||||
@@ -459,7 +422,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}"
|
||||
|
||||
@@ -683,24 +646,6 @@ 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"),
|
||||
@@ -712,8 +657,6 @@ 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"),
|
||||
|
||||
@@ -532,8 +532,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
|
||||
if "require_mention" in platform_cfg:
|
||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||
if "free_response_channels" in platform_cfg:
|
||||
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
|
||||
if "mention_patterns" in platform_cfg:
|
||||
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
||||
if not bridged:
|
||||
@@ -548,19 +546,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
plat_data["extra"] = extra
|
||||
extra.update(bridged)
|
||||
|
||||
# Slack settings → env vars (env vars take precedence)
|
||||
slack_cfg = yaml_cfg.get("slack", {})
|
||||
if isinstance(slack_cfg, dict):
|
||||
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
|
||||
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
|
||||
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
|
||||
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
|
||||
frc = slack_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
|
||||
# Discord settings → env vars (env vars take precedence)
|
||||
discord_cfg = yaml_cfg.get("discord", {})
|
||||
if isinstance(discord_cfg, dict):
|
||||
@@ -901,9 +886,6 @@ 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")
|
||||
|
||||
@@ -299,9 +299,6 @@ 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
|
||||
@@ -327,26 +324,6 @@ 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:
|
||||
@@ -491,12 +468,12 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": self._model_name,
|
||||
"id": "hermes-agent",
|
||||
"object": "model",
|
||||
"created": int(time.time()),
|
||||
"owned_by": "hermes",
|
||||
"permission": [],
|
||||
"root": self._model_name,
|
||||
"root": "hermes-agent",
|
||||
"parent": None,
|
||||
}
|
||||
],
|
||||
@@ -569,7 +546,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
# history already set from request body above
|
||||
|
||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
||||
model_name = body.get("model", self._model_name)
|
||||
model_name = body.get("model", "hermes-agent")
|
||||
created = int(time.time())
|
||||
|
||||
if stream:
|
||||
@@ -946,7 +923,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"created_at": created_at,
|
||||
"model": body.get("model", self._model_name),
|
||||
"model": body.get("model", "hermes-agent"),
|
||||
"output": output_items,
|
||||
"usage": {
|
||||
"input_tokens": usage.get("input_tokens", 0),
|
||||
@@ -1676,8 +1653,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
self._mark_connected()
|
||||
logger.info(
|
||||
"[%s] API server listening on http://%s:%d (model: %s)",
|
||||
self.name, self._host, self._port, self._model_name,
|
||||
"[%s] API server listening on http://%s:%d",
|
||||
self.name, self._host, self._port,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
+1
-125
@@ -10,142 +10,18 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _detect_macos_system_proxy() -> str | None:
|
||||
"""Read the macOS system HTTP(S) proxy via ``scutil --proxy``.
|
||||
|
||||
Returns an ``http://host:port`` URL string if an HTTP or HTTPS proxy is
|
||||
enabled, otherwise *None*. Falls back silently on non-macOS or on any
|
||||
subprocess error.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return None
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["scutil", "--proxy"], timeout=3, text=True, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
props: dict[str, str] = {}
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if " : " in line:
|
||||
key, _, val = line.partition(" : ")
|
||||
props[key.strip()] = val.strip()
|
||||
|
||||
# Prefer HTTPS, fall back to HTTP
|
||||
for enable_key, host_key, port_key in (
|
||||
("HTTPSEnable", "HTTPSProxy", "HTTPSPort"),
|
||||
("HTTPEnable", "HTTPProxy", "HTTPPort"),
|
||||
):
|
||||
if props.get(enable_key) == "1":
|
||||
host = props.get(host_key)
|
||||
port = props.get(port_key)
|
||||
if host and port:
|
||||
return f"http://{host}:{port}"
|
||||
return None
|
||||
|
||||
|
||||
def resolve_proxy_url(platform_env_var: str | None = None) -> str | None:
|
||||
"""Return a proxy URL from env vars, or macOS system proxy.
|
||||
|
||||
Check order:
|
||||
0. *platform_env_var* (e.g. ``DISCORD_PROXY``) — highest priority
|
||||
1. HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants)
|
||||
2. macOS system proxy via ``scutil --proxy`` (auto-detect)
|
||||
|
||||
Returns *None* if no proxy is found.
|
||||
"""
|
||||
if platform_env_var:
|
||||
value = (os.environ.get(platform_env_var) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
|
||||
"https_proxy", "http_proxy", "all_proxy"):
|
||||
value = (os.environ.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return _detect_macos_system_proxy()
|
||||
|
||||
|
||||
def proxy_kwargs_for_bot(proxy_url: str | None) -> dict:
|
||||
"""Build kwargs for ``commands.Bot()`` / ``discord.Client()`` with proxy.
|
||||
|
||||
Returns:
|
||||
- SOCKS URL → ``{"connector": ProxyConnector(..., rdns=True)}``
|
||||
- HTTP URL → ``{"proxy": url}``
|
||||
- *None* → ``{}``
|
||||
|
||||
``rdns=True`` forces remote DNS resolution through the proxy — required
|
||||
by many SOCKS implementations (Shadowrocket, Clash) and essential for
|
||||
bypassing DNS pollution behind the GFW.
|
||||
"""
|
||||
if not proxy_url:
|
||||
return {}
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return {}
|
||||
return {"proxy": proxy_url}
|
||||
|
||||
|
||||
def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
|
||||
|
||||
Returns ``(session_kwargs, request_kwargs)`` where:
|
||||
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
|
||||
- HTTP → ``({}, {"proxy": url})``
|
||||
- None → ``({}, {})``
|
||||
|
||||
Usage::
|
||||
|
||||
sess_kw, req_kw = proxy_kwargs_for_aiohttp(proxy_url)
|
||||
async with aiohttp.ClientSession(**sess_kw) as session:
|
||||
async with session.get(url, **req_kw) as resp:
|
||||
...
|
||||
"""
|
||||
if not proxy_url:
|
||||
return {}, {}
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return {}, {}
|
||||
return {}, {"proxy": proxy_url}
|
||||
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple
|
||||
from enum import Enum
|
||||
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
|
||||
@@ -422,7 +422,6 @@ 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
|
||||
@@ -434,11 +433,6 @@ 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
|
||||
@@ -535,17 +529,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids)
|
||||
intents.voice_states = True
|
||||
|
||||
# Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy)
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_bot
|
||||
proxy_url = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
if proxy_url:
|
||||
logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url)
|
||||
|
||||
# Create bot — proxy= for HTTP, connector= for SOCKS
|
||||
# Create bot
|
||||
self._client = commands.Bot(
|
||||
command_prefix="!", # Not really used, we handle raw messages
|
||||
intents=intents,
|
||||
**proxy_kwargs_for_bot(proxy_url),
|
||||
)
|
||||
adapter_self = self # capture for closure
|
||||
|
||||
@@ -1320,11 +1307,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
# Download the image and send as a Discord file attachment
|
||||
# (Discord renders attachments inline, unlike plain URLs)
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"Failed to download image: HTTP {resp.status}")
|
||||
|
||||
@@ -1601,7 +1585,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
await self._run_simple_slash(interaction, f"/model {name}".strip())
|
||||
|
||||
@tree.command(name="reasoning", description="Show or change reasoning effort")
|
||||
@discord.app_commands.describe(effort="Reasoning effort: none, minimal, low, medium, high, or xhigh.")
|
||||
@discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.")
|
||||
async def slash_reasoning(interaction: discord.Interaction, effort: str = ""):
|
||||
await self._run_simple_slash(interaction, f"/reasoning {effort}".strip())
|
||||
|
||||
@@ -2407,14 +2391,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
else:
|
||||
try:
|
||||
import aiohttp
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
att.url,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"HTTP {resp.status}")
|
||||
@@ -2472,80 +2452,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if thread_id:
|
||||
self._track_thread(thread_id)
|
||||
|
||||
# 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)
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -264,7 +264,6 @@ 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
|
||||
@@ -1015,10 +1014,6 @@ 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
|
||||
@@ -1110,9 +1105,6 @@ 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))),
|
||||
@@ -1160,7 +1152,6 @@ 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
|
||||
@@ -2487,10 +2478,8 @@ 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)
|
||||
@@ -2515,7 +2504,6 @@ 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
|
||||
@@ -2542,22 +2530,10 @@ 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.
|
||||
|
||||
Uses a longer delay when the latest chunk is near Feishu's ~4096-char
|
||||
split point, since a continuation chunk is almost certain.
|
||||
"""
|
||||
"""Flush a pending text batch after the quiet period."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
# 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 asyncio.sleep(self._text_batch_delay_seconds)
|
||||
await self._flush_text_batch_now(key)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
|
||||
@@ -120,11 +120,6 @@ 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)
|
||||
|
||||
@@ -177,13 +172,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"MATRIX_REACTIONS", "true"
|
||||
).lower() not in ("false", "0", "no")
|
||||
|
||||
# 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."""
|
||||
if not event_id:
|
||||
@@ -1100,81 +1088,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Acknowledge receipt so the room shows as read (fire-and-forget).
|
||||
self._background_read_receipt(room.room_id, event.event_id)
|
||||
|
||||
# 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)
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
async def _on_room_message_media(self, room: Any, event: Any) -> None:
|
||||
"""Handle incoming media messages (images, audio, video, files)."""
|
||||
|
||||
+65
-219
@@ -14,7 +14,6 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional, Any, Tuple
|
||||
|
||||
try:
|
||||
@@ -46,14 +45,6 @@ from gateway.platforms.base import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ThreadContextCache:
|
||||
"""Cache entry for fetched thread context."""
|
||||
content: str
|
||||
fetched_at: float = field(default_factory=time.monotonic)
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
def check_slack_requirements() -> bool:
|
||||
"""Check if Slack dependencies are available."""
|
||||
return SLACK_AVAILABLE
|
||||
@@ -110,9 +101,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
# session + memory scoping.
|
||||
self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {}
|
||||
self._ASSISTANT_THREADS_MAX = 5000
|
||||
# Cache for _fetch_thread_context results: cache_key → _ThreadContextCache
|
||||
self._thread_context_cache: Dict[str, _ThreadContextCache] = {}
|
||||
self._THREAD_CACHE_TTL = 60.0
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
@@ -293,7 +281,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
kwargs = {
|
||||
"channel": chat_id,
|
||||
"text": chunk,
|
||||
"mrkdwn": True,
|
||||
}
|
||||
if thread_ts:
|
||||
kwargs["thread_ts"] = thread_ts
|
||||
@@ -336,7 +323,9 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if not self._app:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
# Convert standard markdown → Slack mrkdwn
|
||||
formatted = self.format_message(content)
|
||||
|
||||
await self._get_client(chat_id).chat_update(
|
||||
channel=chat_id,
|
||||
ts=message_id,
|
||||
@@ -468,36 +457,13 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)
|
||||
|
||||
# 3) Convert markdown links [text](url) → <url|text>
|
||||
def _convert_markdown_link(m):
|
||||
label = m.group(1)
|
||||
url = m.group(2).strip()
|
||||
if url.startswith('<') and url.endswith('>'):
|
||||
url = url[1:-1].strip()
|
||||
return _ph(f'<{url}|{label}>')
|
||||
|
||||
text = re.sub(
|
||||
r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)',
|
||||
_convert_markdown_link,
|
||||
r'\[([^\]]+)\]\(([^)]+)\)',
|
||||
lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'),
|
||||
text,
|
||||
)
|
||||
|
||||
# 4) Protect existing Slack entities/manual links so escaping and later
|
||||
# formatting passes don't break them.
|
||||
text = re.sub(
|
||||
r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)',
|
||||
lambda m: _ph(m.group(1)),
|
||||
text,
|
||||
)
|
||||
|
||||
# 5) Protect blockquote markers before escaping
|
||||
text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE)
|
||||
|
||||
# 6) Escape Slack control characters in remaining plain text.
|
||||
# Unescape first so already-escaped input doesn't get double-escaped.
|
||||
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
# 7) Convert headers (## Title) → *Title* (bold)
|
||||
# 4) Convert headers (## Title) → *Title* (bold)
|
||||
def _convert_header(m):
|
||||
inner = m.group(1).strip()
|
||||
# Strip redundant bold markers inside a header
|
||||
@@ -508,39 +474,34 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
|
||||
)
|
||||
|
||||
# 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic)
|
||||
text = re.sub(
|
||||
r'\*\*\*(.+?)\*\*\*',
|
||||
lambda m: _ph(f'*_{m.group(1)}_*'),
|
||||
text,
|
||||
)
|
||||
|
||||
# 9) Convert bold: **text** → *text* (Slack bold)
|
||||
# 5) Convert bold: **text** → *text* (Slack bold)
|
||||
text = re.sub(
|
||||
r'\*\*(.+?)\*\*',
|
||||
lambda m: _ph(f'*{m.group(1)}*'),
|
||||
text,
|
||||
)
|
||||
|
||||
# 10) Convert italic: _text_ stays as _text_ (already Slack italic)
|
||||
# Single *text* → _text_ (Slack italic)
|
||||
# 6) Convert italic: _text_ stays as _text_ (already Slack italic)
|
||||
# Single *text* → _text_ (Slack italic)
|
||||
text = re.sub(
|
||||
r'(?<!\*)\*([^*\n]+)\*(?!\*)',
|
||||
lambda m: _ph(f'_{m.group(1)}_'),
|
||||
text,
|
||||
)
|
||||
|
||||
# 11) Convert strikethrough: ~~text~~ → ~text~
|
||||
# 7) Convert strikethrough: ~~text~~ → ~text~
|
||||
text = re.sub(
|
||||
r'~~(.+?)~~',
|
||||
lambda m: _ph(f'~{m.group(1)}~'),
|
||||
text,
|
||||
)
|
||||
|
||||
# 12) Blockquotes: > prefix is already protected by step 5 above.
|
||||
# 8) Convert blockquotes: > text → > text (same syntax, just ensure
|
||||
# no extra escaping happens to the > character)
|
||||
# Slack uses the same > prefix, so this is a no-op for content.
|
||||
|
||||
# 13) Restore placeholders in reverse order
|
||||
for key in reversed(placeholders):
|
||||
# 9) Restore placeholders in reverse order
|
||||
for key in reversed(list(placeholders.keys())):
|
||||
text = text.replace(key, placeholders[key])
|
||||
|
||||
return text
|
||||
@@ -953,26 +914,9 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if v > cutoff
|
||||
}
|
||||
|
||||
# Bot message filtering (SLACK_ALLOW_BOTS / config allow_bots):
|
||||
# "none" — ignore all bot messages (default, backward-compatible)
|
||||
# "mentions" — accept bot messages only when they @mention us
|
||||
# "all" — accept all bot messages (except our own)
|
||||
# Ignore bot messages (including our own)
|
||||
if event.get("bot_id") or event.get("subtype") == "bot_message":
|
||||
allow_bots = self.config.extra.get("allow_bots", "")
|
||||
if not allow_bots:
|
||||
allow_bots = os.getenv("SLACK_ALLOW_BOTS", "none")
|
||||
allow_bots = str(allow_bots).lower().strip()
|
||||
if allow_bots == "none":
|
||||
return
|
||||
elif allow_bots == "mentions":
|
||||
text_check = event.get("text", "")
|
||||
if self._bot_user_id and f"<@{self._bot_user_id}>" not in text_check:
|
||||
return
|
||||
# "all" falls through to process the message
|
||||
# Always ignore our own messages to prevent echo loops
|
||||
msg_user = event.get("user", "")
|
||||
if msg_user and self._bot_user_id and msg_user == self._bot_user_id:
|
||||
return
|
||||
return
|
||||
|
||||
# Ignore message edits and deletions
|
||||
subtype = event.get("subtype")
|
||||
@@ -1004,7 +948,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
channel_type = event.get("channel_type", "")
|
||||
if not channel_type and channel_id.startswith("D"):
|
||||
channel_type = "im"
|
||||
is_dm = channel_type in ("im", "mpim") # Both 1:1 and group DMs
|
||||
is_dm = channel_type == "im"
|
||||
|
||||
# Build thread_ts for session keying.
|
||||
# In channels: fall back to ts so each top-level @mention starts a
|
||||
@@ -1017,8 +961,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
|
||||
|
||||
# In channels, respond if:
|
||||
# 0. Channel is in free_response_channels, OR require_mention is
|
||||
# disabled — always process regardless of mention.
|
||||
# 1. The bot is @mentioned in this message, OR
|
||||
# 2. The message is a reply in a thread the bot started/participated in, OR
|
||||
# 3. The message is in a thread where the bot was previously @mentioned, OR
|
||||
@@ -1028,29 +970,24 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
event_thread_ts = event.get("thread_ts")
|
||||
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
|
||||
|
||||
if not is_dm and bot_uid:
|
||||
if channel_id in self._slack_free_response_channels():
|
||||
pass # Free-response channel — always process
|
||||
elif not self._slack_require_mention():
|
||||
pass # Mention requirement disabled globally for Slack
|
||||
elif not is_mentioned:
|
||||
reply_to_bot_thread = (
|
||||
is_thread_reply and event_thread_ts in self._bot_message_ts
|
||||
if not is_dm and bot_uid and not is_mentioned:
|
||||
reply_to_bot_thread = (
|
||||
is_thread_reply and event_thread_ts in self._bot_message_ts
|
||||
)
|
||||
in_mentioned_thread = (
|
||||
event_thread_ts is not None
|
||||
and event_thread_ts in self._mentioned_threads
|
||||
)
|
||||
has_session = (
|
||||
is_thread_reply
|
||||
and self._has_active_session_for_thread(
|
||||
channel_id=channel_id,
|
||||
thread_ts=event_thread_ts,
|
||||
user_id=user_id,
|
||||
)
|
||||
in_mentioned_thread = (
|
||||
event_thread_ts is not None
|
||||
and event_thread_ts in self._mentioned_threads
|
||||
)
|
||||
has_session = (
|
||||
is_thread_reply
|
||||
and self._has_active_session_for_thread(
|
||||
channel_id=channel_id,
|
||||
thread_ts=event_thread_ts,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
|
||||
return
|
||||
)
|
||||
if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
|
||||
return
|
||||
|
||||
if is_mentioned:
|
||||
# Strip the bot mention from the text
|
||||
@@ -1191,19 +1128,14 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
reply_to_message_id=thread_ts if thread_ts != ts else None,
|
||||
)
|
||||
|
||||
# Only react when bot is directly addressed (DM or @mention).
|
||||
# In listen-all channels (require_mention=false), reacting to every
|
||||
# casual message would be noisy.
|
||||
_should_react = is_dm or is_mentioned
|
||||
|
||||
if _should_react:
|
||||
await self._add_reaction(channel_id, ts, "eyes")
|
||||
# Add 👀 reaction to acknowledge receipt
|
||||
await self._add_reaction(channel_id, ts, "eyes")
|
||||
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
if _should_react:
|
||||
await self._remove_reaction(channel_id, ts, "eyes")
|
||||
await self._add_reaction(channel_id, ts, "white_check_mark")
|
||||
# Replace 👀 with ✅ when done
|
||||
await self._remove_reaction(channel_id, ts, "eyes")
|
||||
await self._add_reaction(channel_id, ts, "white_check_mark")
|
||||
|
||||
# ----- Approval button support (Block Kit) -----
|
||||
|
||||
@@ -1297,20 +1229,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
msg_ts = message.get("ts", "")
|
||||
channel_id = body.get("channel", {}).get("id", "")
|
||||
user_name = body.get("user", {}).get("name", "unknown")
|
||||
user_id = body.get("user", {}).get("id", "")
|
||||
|
||||
# Only authorized users may click approval buttons. Button clicks
|
||||
# bypass the normal message auth flow in gateway/run.py, so we must
|
||||
# check here as well.
|
||||
allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
|
||||
if allowed_csv:
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
if "*" not in allowed_ids and user_id not in allowed_ids:
|
||||
logger.warning(
|
||||
"[Slack] Unauthorized approval click by %s (%s) — ignoring",
|
||||
user_name, user_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Map action_id to approval choice
|
||||
choice_map = {
|
||||
@@ -1321,9 +1239,10 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
}
|
||||
choice = choice_map.get(action_id, "deny")
|
||||
|
||||
# Prevent double-clicks — atomic pop; first caller gets False, others get True (default)
|
||||
if self._approval_resolved.pop(msg_ts, True):
|
||||
# Prevent double-clicks
|
||||
if self._approval_resolved.get(msg_ts, False):
|
||||
return
|
||||
self._approval_resolved[msg_ts] = True
|
||||
|
||||
# Update the message to show the decision and remove buttons
|
||||
label_map = {
|
||||
@@ -1378,7 +1297,8 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Slack button: %s", exc)
|
||||
|
||||
# (approval state already consumed by atomic pop above)
|
||||
# Clean up stale approval state
|
||||
self._approval_resolved.pop(msg_ts, None)
|
||||
|
||||
# ----- Thread context fetching -----
|
||||
|
||||
@@ -1389,104 +1309,57 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
"""Fetch recent thread messages to provide context when the bot is
|
||||
mentioned mid-thread for the first time.
|
||||
|
||||
This method is only called when there is NO active session for the
|
||||
thread (guarded at the call site by _has_active_session_for_thread).
|
||||
That guard ensures thread messages are prepended only on the very
|
||||
first turn — after that the session history already holds them, so
|
||||
there is no duplication across subsequent turns.
|
||||
|
||||
Results are cached for _THREAD_CACHE_TTL seconds per thread to avoid
|
||||
hammering conversations.replies (Tier 3, ~50 req/min).
|
||||
|
||||
Returns a formatted string with prior thread history, or empty string
|
||||
on failure or if the thread has no prior messages.
|
||||
Returns a formatted string with thread history, or empty string on
|
||||
failure or if the thread is empty (just the parent message).
|
||||
"""
|
||||
cache_key = f"{channel_id}:{thread_ts}"
|
||||
now = time.monotonic()
|
||||
cached = self._thread_context_cache.get(cache_key)
|
||||
if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:
|
||||
return cached.content
|
||||
|
||||
try:
|
||||
client = self._get_client(channel_id)
|
||||
|
||||
# Retry with exponential backoff for Tier-3 rate limits (429).
|
||||
result = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=thread_ts,
|
||||
limit=limit + 1, # +1 because it includes the current message
|
||||
inclusive=True,
|
||||
)
|
||||
break
|
||||
except Exception as exc:
|
||||
# Check for rate-limit error from slack_sdk
|
||||
err_str = str(exc).lower()
|
||||
is_rate_limit = (
|
||||
"ratelimited" in err_str
|
||||
or "429" in err_str
|
||||
or "rate_limited" in err_str
|
||||
)
|
||||
if is_rate_limit and attempt < 2:
|
||||
retry_after = 1.0 * (2 ** attempt) # 1s, 2s
|
||||
logger.warning(
|
||||
"[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)",
|
||||
retry_after, attempt + 1,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
raise
|
||||
|
||||
if result is None:
|
||||
return ""
|
||||
|
||||
result = await client.conversations_replies(
|
||||
channel=channel_id,
|
||||
ts=thread_ts,
|
||||
limit=limit + 1, # +1 because it includes the current message
|
||||
inclusive=True,
|
||||
)
|
||||
messages = result.get("messages", [])
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
|
||||
context_parts = []
|
||||
for msg in messages:
|
||||
msg_ts = msg.get("ts", "")
|
||||
# Exclude the current triggering message — it will be delivered
|
||||
# as the user message itself, so including it here would duplicate it.
|
||||
# Skip the current message (the one that triggered this fetch)
|
||||
if msg_ts == current_ts:
|
||||
continue
|
||||
# Exclude our own bot messages to avoid circular context.
|
||||
# Skip bot messages from ourselves
|
||||
if msg.get("bot_id") or msg.get("subtype") == "bot_message":
|
||||
continue
|
||||
|
||||
msg_user = msg.get("user", "unknown")
|
||||
msg_text = msg.get("text", "").strip()
|
||||
if not msg_text:
|
||||
continue
|
||||
|
||||
# Strip bot mentions from context messages
|
||||
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
|
||||
if bot_uid:
|
||||
msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip()
|
||||
|
||||
msg_user = msg.get("user", "unknown")
|
||||
# Mark the thread parent
|
||||
is_parent = msg_ts == thread_ts
|
||||
prefix = "[thread parent] " if is_parent else ""
|
||||
|
||||
# Resolve user name (cached)
|
||||
name = await self._resolve_user_name(msg_user, chat_id=channel_id)
|
||||
context_parts.append(f"{prefix}{name}: {msg_text}")
|
||||
|
||||
content = ""
|
||||
if context_parts:
|
||||
content = (
|
||||
"[Thread context — prior messages in this thread (not yet in conversation history):]\n"
|
||||
+ "\n".join(context_parts)
|
||||
+ "\n[End of thread context]\n\n"
|
||||
)
|
||||
if not context_parts:
|
||||
return ""
|
||||
|
||||
self._thread_context_cache[cache_key] = _ThreadContextCache(
|
||||
content=content,
|
||||
fetched_at=now,
|
||||
message_count=len(context_parts),
|
||||
return (
|
||||
"[Thread context — previous messages in this thread:]\n"
|
||||
+ "\n".join(context_parts)
|
||||
+ "\n[End of thread context]\n\n"
|
||||
)
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[Slack] Failed to fetch thread context: %s", e)
|
||||
return ""
|
||||
@@ -1642,30 +1515,3 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
continue
|
||||
raise
|
||||
raise last_exc
|
||||
|
||||
# ── Channel mention gating ─────────────────────────────────────────────
|
||||
|
||||
def _slack_require_mention(self) -> bool:
|
||||
"""Return whether channel messages require an explicit bot mention.
|
||||
|
||||
Uses explicit-false parsing (like Discord/Matrix) rather than
|
||||
truthy parsing, since the safe default is True (gating on).
|
||||
Unrecognised or empty values keep gating enabled.
|
||||
"""
|
||||
configured = self.config.extra.get("require_mention")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() not in ("false", "0", "no", "off")
|
||||
return bool(configured)
|
||||
return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
|
||||
|
||||
def _slack_free_response_channels(self) -> set:
|
||||
"""Return channel IDs where no @mention is required."""
|
||||
raw = self.config.extra.get("free_response_channels")
|
||||
if raw is None:
|
||||
raw = os.getenv("SLACK_FREE_RESPONSE_CHANNELS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
@@ -121,9 +121,6 @@ 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):
|
||||
@@ -143,7 +140,6 @@ 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
|
||||
@@ -1402,15 +1398,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
await query.answer(text="Invalid approval data.")
|
||||
return
|
||||
|
||||
# Only authorized users may click approval buttons.
|
||||
caller_id = str(getattr(query.from_user, "id", ""))
|
||||
allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
|
||||
if allowed_csv:
|
||||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||||
if "*" not in allowed_ids and caller_id not in allowed_ids:
|
||||
await query.answer(text="⛔ You are not authorized to approve commands.")
|
||||
return
|
||||
|
||||
session_key = self._approval_state.pop(approval_id, None)
|
||||
if not session_key:
|
||||
await query.answer(text="This approval has already been resolved.")
|
||||
@@ -2164,15 +2151,12 @@ 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)
|
||||
@@ -2187,22 +2171,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
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 Telegram's 4096-char
|
||||
split point, since a continuation chunk is almost certain.
|
||||
"""
|
||||
"""Wait for the quiet period then dispatch the aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
# 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)
|
||||
await asyncio.sleep(self._text_batch_delay_seconds)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
|
||||
@@ -45,9 +45,11 @@ _SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"]
|
||||
|
||||
|
||||
def _resolve_proxy_url() -> str | None:
|
||||
# Delegate to shared implementation (env vars + macOS system proxy detection)
|
||||
from gateway.platforms.base import resolve_proxy_url
|
||||
return resolve_proxy_url()
|
||||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"):
|
||||
value = (os.environ.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
||||
|
||||
@@ -143,9 +143,6 @@ 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)
|
||||
@@ -175,13 +172,6 @@ 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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -529,82 +519,7 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
# 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)
|
||||
await self.handle_message(event)
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]:
|
||||
|
||||
+13
-62
@@ -925,8 +925,8 @@ class GatewayRunner:
|
||||
def _load_reasoning_config() -> dict | None:
|
||||
"""Load reasoning effort from config.yaml.
|
||||
|
||||
Reads agent.reasoning_effort from config.yaml. Valid: "none",
|
||||
"minimal", "low", "medium", "high", "xhigh". Returns None to use
|
||||
Reads agent.reasoning_effort from config.yaml. Valid: "xhigh",
|
||||
"high", "medium", "low", "minimal", "none". Returns None to use
|
||||
default (medium).
|
||||
"""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
@@ -4840,7 +4840,7 @@ class GatewayRunner:
|
||||
|
||||
Usage:
|
||||
/reasoning Show current effort level and display state
|
||||
/reasoning <level> Set reasoning effort (none, minimal, low, medium, high, xhigh)
|
||||
/reasoning <level> Set reasoning effort (none, low, medium, high, xhigh)
|
||||
/reasoning show|on Show model reasoning in responses
|
||||
/reasoning hide|off Hide model reasoning from responses
|
||||
"""
|
||||
@@ -4885,7 +4885,7 @@ class GatewayRunner:
|
||||
"🧠 **Reasoning Settings**\n\n"
|
||||
f"**Effort:** `{level}`\n"
|
||||
f"**Display:** {display_state}\n\n"
|
||||
"_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide>`"
|
||||
"_Usage:_ `/reasoning <none|low|medium|high|xhigh|show|hide>`"
|
||||
)
|
||||
|
||||
# Display toggle
|
||||
@@ -4903,12 +4903,12 @@ class GatewayRunner:
|
||||
effort = args.strip()
|
||||
if effort == "none":
|
||||
parsed = {"enabled": False}
|
||||
elif effort in ("minimal", "low", "medium", "high", "xhigh"):
|
||||
elif effort in ("xhigh", "high", "medium", "low", "minimal"):
|
||||
parsed = {"enabled": True, "effort": effort}
|
||||
else:
|
||||
return (
|
||||
f"⚠️ Unknown argument: `{effort}`\n\n"
|
||||
"**Valid levels:** none, minimal, low, medium, high, xhigh\n"
|
||||
"**Valid levels:** none, low, minimal, medium, high, xhigh\n"
|
||||
"**Display:** show, hide"
|
||||
)
|
||||
|
||||
@@ -5274,76 +5274,27 @@ class GatewayRunner:
|
||||
)
|
||||
|
||||
async def _handle_usage_command(self, event: MessageEvent) -> str:
|
||||
"""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.
|
||||
"""
|
||||
"""Handle /usage command -- show token usage for the session's last agent run."""
|
||||
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 (when available from provider headers)
|
||||
# Rate limits first (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 — 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
|
||||
|
||||
# Session token usage
|
||||
lines.append("📊 **Session Token Usage**")
|
||||
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"Prompt (input): {agent.session_prompt_tokens:,}")
|
||||
lines.append(f"Completion (output): {agent.session_completion_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
|
||||
@@ -5353,7 +5304,7 @@ class GatewayRunner:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# No agent at all -- check session history for a rough count
|
||||
# No running agent -- 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:
|
||||
@@ -5364,7 +5315,7 @@ class GatewayRunner:
|
||||
f"📊 **Session Info**\n"
|
||||
f"Messages: {len(msgs)}\n"
|
||||
f"Estimated context: ~{approx:,} tokens\n"
|
||||
f"_(Detailed usage available after the first agent response)_"
|
||||
f"_(Detailed usage available during active conversations)_"
|
||||
)
|
||||
return "No usage data available for this session."
|
||||
|
||||
|
||||
@@ -136,34 +136,7 @@ class GatewayStreamConsumer:
|
||||
|
||||
if should_edit and self._accumulated:
|
||||
# Split overflow: if accumulated text exceeds the platform
|
||||
# limit, split into properly sized chunks.
|
||||
if (
|
||||
len(self._accumulated) > _safe_limit
|
||||
and self._message_id is None
|
||||
):
|
||||
# No existing message to edit (first message or after a
|
||||
# segment break). Use truncate_message — the same
|
||||
# helper the non-streaming path uses — to split with
|
||||
# proper word/code-fence boundaries and chunk
|
||||
# indicators like "(1/2)".
|
||||
chunks = self.adapter.truncate_message(
|
||||
self._accumulated, _safe_limit
|
||||
)
|
||||
for chunk in chunks:
|
||||
await self._send_new_chunk(chunk, self._message_id)
|
||||
self._accumulated = ""
|
||||
self._last_sent_text = ""
|
||||
self._last_edit_time = time.monotonic()
|
||||
if got_done:
|
||||
return
|
||||
if got_segment_break:
|
||||
self._message_id = None
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
continue
|
||||
|
||||
# Existing message: edit it with the first chunk, then
|
||||
# start a new message for the overflow remainder.
|
||||
# limit, finalize the current message and start a new one.
|
||||
while (
|
||||
len(self._accumulated) > _safe_limit
|
||||
and self._message_id is not None
|
||||
@@ -253,34 +226,6 @@ class GatewayStreamConsumer:
|
||||
# Strip trailing whitespace/newlines but preserve leading content
|
||||
return cleaned.rstrip()
|
||||
|
||||
async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]:
|
||||
"""Send a new message chunk, optionally threaded to a previous message.
|
||||
|
||||
Returns the message_id so callers can thread subsequent chunks.
|
||||
"""
|
||||
text = self._clean_for_display(text)
|
||||
if not text.strip():
|
||||
return reply_to_id
|
||||
try:
|
||||
meta = dict(self.metadata) if self.metadata else {}
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=reply_to_id,
|
||||
metadata=meta,
|
||||
)
|
||||
if result.success and result.message_id:
|
||||
self._message_id = str(result.message_id)
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
return str(result.message_id)
|
||||
else:
|
||||
self._edit_supported = False
|
||||
return reply_to_id
|
||||
except Exception as e:
|
||||
logger.error("Stream send chunk error: %s", e)
|
||||
return reply_to_id
|
||||
|
||||
def _visible_prefix(self) -> str:
|
||||
"""Return the visible text already shown in the streamed message."""
|
||||
prefix = self._last_sent_text or ""
|
||||
|
||||
+2
-23
@@ -99,10 +99,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
"Configuration"),
|
||||
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",
|
||||
cli_only=True, args_hint="[normal|fast|status]",
|
||||
subcommands=("normal", "fast", "status", "on", "off")),
|
||||
subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
@@ -138,8 +135,6 @@ 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),
|
||||
|
||||
@@ -642,18 +637,8 @@ 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:
|
||||
@@ -931,7 +916,7 @@ class SlashCommandCompleter(Completer):
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
if sub.startswith(sub_lower) and sub != sub_lower:
|
||||
yield Completion(
|
||||
@@ -944,8 +929,6 @@ 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(
|
||||
@@ -1004,8 +987,6 @@ 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):])
|
||||
@@ -1016,8 +997,6 @@ 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]:
|
||||
|
||||
@@ -255,7 +255,6 @@ 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
|
||||
@@ -1217,14 +1216,6 @@ 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)",
|
||||
|
||||
+8
-52
@@ -54,32 +54,6 @@ _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)
|
||||
@@ -226,7 +200,7 @@ def run_doctor(args):
|
||||
check_ok(name)
|
||||
except ImportError:
|
||||
check_fail(name, "(missing)")
|
||||
issues.append(f"Install {name}: {_python_install_cmd()} {module}")
|
||||
issues.append(f"Install {name}: uv pip install {module}")
|
||||
|
||||
for module, name in optional_packages:
|
||||
try:
|
||||
@@ -529,7 +503,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(f"Install for faster search: {_system_package_install_cmd('ripgrep')}")
|
||||
check_info("Install for faster search: sudo apt install ripgrep")
|
||||
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
@@ -552,10 +526,7 @@ def run_doctor(args):
|
||||
if shutil.which("docker"):
|
||||
check_ok("docker", "(optional)")
|
||||
else:
|
||||
if _is_termux():
|
||||
check_info("Docker backend is not available inside Termux (expected on Android)")
|
||||
else:
|
||||
check_warn("docker not found", "(optional)")
|
||||
check_warn("docker not found", "(optional)")
|
||||
|
||||
# SSH (if using ssh backend)
|
||||
if terminal_env == "ssh":
|
||||
@@ -603,23 +574,9 @@ def run_doctor(args):
|
||||
if agent_browser_path.exists():
|
||||
check_ok("agent-browser (Node.js)", "(browser automation)")
|
||||
else:
|
||||
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)")
|
||||
check_warn("agent-browser not installed", "(run: npm install)")
|
||||
else:
|
||||
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)")
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
if shutil.which("npm"):
|
||||
@@ -752,7 +709,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.30.0"
|
||||
_headers["User-Agent"] = "KimiCLI/1.0"
|
||||
_resp = httpx.get(
|
||||
_url,
|
||||
headers=_headers,
|
||||
@@ -782,9 +739,8 @@ def run_doctor(args):
|
||||
__import__("tinker_atropos")
|
||||
check_ok("tinker-atropos", "(RL training backend)")
|
||||
except ImportError:
|
||||
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}")
|
||||
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")
|
||||
else:
|
||||
check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})")
|
||||
else:
|
||||
|
||||
+29
-64
@@ -39,7 +39,7 @@ def _get_service_pids() -> set:
|
||||
pids: set = set()
|
||||
|
||||
# --- systemd (Linux): user and system scopes ---
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
for scope_args in [["systemctl", "--user"], ["systemctl"]]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -225,14 +225,6 @@ 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'
|
||||
|
||||
@@ -485,15 +477,13 @@ def install_linux_gateway_from_setup(force: bool = False) -> tuple[str | None, b
|
||||
|
||||
|
||||
def get_systemd_linger_status() -> tuple[bool | None, str]:
|
||||
"""Return systemd linger status for the current user.
|
||||
"""Return whether systemd user lingering is enabled 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"
|
||||
|
||||
@@ -776,7 +766,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 is_termux() or not is_linux():
|
||||
if not is_linux():
|
||||
return
|
||||
|
||||
import getpass
|
||||
@@ -1811,7 +1801,7 @@ def _setup_whatsapp():
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
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()
|
||||
@@ -1820,7 +1810,7 @@ def _is_service_installed() -> bool:
|
||||
|
||||
def _is_service_running() -> bool:
|
||||
"""Check if the gateway service is currently running."""
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
user_unit_exists = get_systemd_unit_path(system=False).exists()
|
||||
system_unit_exists = get_systemd_unit_path(system=True).exists()
|
||||
|
||||
@@ -1993,7 +1983,7 @@ def gateway_setup():
|
||||
service_installed = _is_service_installed()
|
||||
service_running = _is_service_running()
|
||||
|
||||
if supports_systemd_services() and has_conflicting_systemd_units():
|
||||
if is_linux() and has_conflicting_systemd_units():
|
||||
print_systemd_scope_conflict_warning()
|
||||
print()
|
||||
|
||||
@@ -2003,7 +1993,7 @@ def gateway_setup():
|
||||
print_warning("Gateway service is installed but not running.")
|
||||
if prompt_yes_no(" Start it now?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
@@ -2054,7 +2044,7 @@ def gateway_setup():
|
||||
if service_running:
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
systemd_restart()
|
||||
elif is_macos():
|
||||
launchd_restart()
|
||||
@@ -2066,7 +2056,7 @@ def gateway_setup():
|
||||
elif service_installed:
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
@@ -2074,13 +2064,13 @@ def gateway_setup():
|
||||
print_error(f" Start failed: {e}")
|
||||
else:
|
||||
print()
|
||||
if supports_systemd_services() or is_macos():
|
||||
platform_name = "systemd" if supports_systemd_services() else "launchd"
|
||||
if is_linux() or is_macos():
|
||||
platform_name = "systemd" if is_linux() 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 supports_systemd_services():
|
||||
if is_linux():
|
||||
installed_scope, did_install = install_linux_gateway_from_setup(force=False)
|
||||
else:
|
||||
launchd_install(force=False)
|
||||
@@ -2088,7 +2078,7 @@ def gateway_setup():
|
||||
print()
|
||||
if did_install and prompt_yes_no(" Start the service now?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
systemd_start(system=installed_scope == "system")
|
||||
else:
|
||||
launchd_start()
|
||||
@@ -2099,18 +2089,12 @@ def gateway_setup():
|
||||
print_info(" You can try manually: hermes gateway install")
|
||||
else:
|
||||
print_info(" You can install later: hermes gateway install")
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
print_info(" Or as a boot-time service: sudo hermes gateway install --system")
|
||||
print_info(" Or run in foreground: hermes gateway")
|
||||
else:
|
||||
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")
|
||||
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.")
|
||||
@@ -2146,11 +2130,7 @@ 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_termux():
|
||||
print("Gateway service installation is not supported on Termux.")
|
||||
print("Run manually: hermes gateway")
|
||||
sys.exit(1)
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
systemd_install(force=force, system=system, run_as_user=run_as_user)
|
||||
elif is_macos():
|
||||
launchd_install(force)
|
||||
@@ -2164,11 +2144,7 @@ def gateway_command(args):
|
||||
managed_error("uninstall gateway service (managed by NixOS)")
|
||||
return
|
||||
system = getattr(args, 'system', False)
|
||||
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():
|
||||
if is_linux():
|
||||
systemd_uninstall(system=system)
|
||||
elif is_macos():
|
||||
launchd_uninstall()
|
||||
@@ -2178,11 +2154,7 @@ def gateway_command(args):
|
||||
|
||||
elif subcmd == "start":
|
||||
system = getattr(args, 'system', False)
|
||||
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():
|
||||
if is_linux():
|
||||
systemd_start(system=system)
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
@@ -2197,7 +2169,7 @@ def gateway_command(args):
|
||||
if stop_all:
|
||||
# --all: kill every gateway process on the machine
|
||||
service_available = False
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
try:
|
||||
systemd_stop(system=system)
|
||||
service_available = True
|
||||
@@ -2218,7 +2190,7 @@ def gateway_command(args):
|
||||
else:
|
||||
# Default: stop only the current profile's gateway
|
||||
service_available = False
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
try:
|
||||
systemd_stop(system=system)
|
||||
service_available = True
|
||||
@@ -2246,7 +2218,7 @@ def gateway_command(args):
|
||||
system = getattr(args, 'system', False)
|
||||
service_configured = False
|
||||
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
service_configured = True
|
||||
try:
|
||||
systemd_restart(system=system)
|
||||
@@ -2263,7 +2235,7 @@ def gateway_command(args):
|
||||
|
||||
if not service_available:
|
||||
# systemd/launchd restart failed — check if linger is the issue
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
linger_ok, _detail = get_systemd_linger_status()
|
||||
if linger_ok is not True:
|
||||
import getpass
|
||||
@@ -2300,7 +2272,7 @@ def gateway_command(args):
|
||||
system = getattr(args, 'system', False)
|
||||
|
||||
# Check for service first
|
||||
if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
|
||||
if is_linux() 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)
|
||||
@@ -2317,13 +2289,9 @@ def gateway_command(args):
|
||||
for line in runtime_lines:
|
||||
print(f" {line}")
|
||||
print()
|
||||
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")
|
||||
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()
|
||||
@@ -2335,8 +2303,5 @@ def gateway_command(args):
|
||||
print()
|
||||
print("To start:")
|
||||
print(" hermes gateway # Run in foreground")
|
||||
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")
|
||||
print(" hermes gateway install # Install as user service")
|
||||
print(" sudo hermes gateway install --system # Install as boot-time system service")
|
||||
|
||||
+30
-24
@@ -646,7 +646,6 @@ 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),
|
||||
@@ -1812,10 +1811,7 @@ def _set_reasoning_effort(config, effort: str) -> None:
|
||||
|
||||
def _prompt_reasoning_effort_selection(efforts, current_effort=""):
|
||||
"""Prompt for a reasoning effort. Returns effort, 'none', or None to keep current."""
|
||||
deduped = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip()))
|
||||
canonical_order = ("minimal", "low", "medium", "high", "xhigh")
|
||||
ordered = [effort for effort in canonical_order if effort in deduped]
|
||||
ordered.extend(effort for effort in deduped if effort not in canonical_order)
|
||||
ordered = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip()))
|
||||
if not ordered:
|
||||
return None
|
||||
|
||||
@@ -3022,19 +3018,33 @@ def _restore_stashed_changes(
|
||||
print("\nYour stashed changes are preserved — nothing is lost.")
|
||||
print(f" Stash ref: {stash_ref}")
|
||||
|
||||
# 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.
|
||||
# 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)
|
||||
return False
|
||||
|
||||
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
|
||||
@@ -3750,7 +3760,7 @@ def cmd_update(args):
|
||||
# running gateway needs restarting to pick up the new code.
|
||||
try:
|
||||
from hermes_cli.gateway import (
|
||||
is_macos, supports_systemd_services, _ensure_user_systemd_env,
|
||||
is_macos, is_linux, _ensure_user_systemd_env,
|
||||
find_gateway_pids,
|
||||
_get_service_pids,
|
||||
)
|
||||
@@ -3761,7 +3771,7 @@ def cmd_update(args):
|
||||
|
||||
# --- Systemd services (Linux) ---
|
||||
# Discover all hermes-gateway* units (default + profiles)
|
||||
if supports_systemd_services():
|
||||
if is_linux():
|
||||
try:
|
||||
_ensure_user_systemd_env()
|
||||
except Exception:
|
||||
@@ -4278,10 +4288,6 @@ 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)"
|
||||
|
||||
@@ -1017,79 +1017,6 @@ 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:
|
||||
|
||||
@@ -16,7 +16,6 @@ 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,
|
||||
@@ -645,21 +644,6 @@ 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,
|
||||
|
||||
+2
-23
@@ -79,9 +79,6 @@ 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)
|
||||
@@ -328,25 +325,7 @@ def show_status(args):
|
||||
print()
|
||||
print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
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'):
|
||||
if sys.platform.startswith('linux'):
|
||||
try:
|
||||
from hermes_cli.gateway import get_service_name
|
||||
_gw_svc = get_service_name()
|
||||
@@ -360,7 +339,7 @@ def show_status(args):
|
||||
timeout=5
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
except subprocess.TimeoutExpired:
|
||||
is_active = False
|
||||
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
@@ -122,10 +122,6 @@ 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
|
||||
|
||||
+2
-12
@@ -72,13 +72,13 @@ def display_hermes_home() -> str:
|
||||
return str(home)
|
||||
|
||||
|
||||
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")
|
||||
VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal")
|
||||
|
||||
|
||||
def parse_reasoning_effort(effort: str) -> dict | None:
|
||||
"""Parse a reasoning effort level into a config dict.
|
||||
|
||||
Valid levels: "none", "minimal", "low", "medium", "high", "xhigh".
|
||||
Valid levels: "xhigh", "high", "medium", "low", "minimal", "none".
|
||||
Returns None when the input is empty or unrecognized (caller uses default).
|
||||
Returns {"enabled": False} for "none".
|
||||
Returns {"enabled": True, "effort": <level>} for valid effort levels.
|
||||
@@ -93,16 +93,6 @@ 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"
|
||||
OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions"
|
||||
|
||||
+4
-8
@@ -944,8 +944,7 @@ class SessionDB:
|
||||
try:
|
||||
msg["tool_calls"] = json.loads(msg["tool_calls"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
|
||||
msg["tool_calls"] = []
|
||||
pass
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
@@ -973,8 +972,7 @@ class SessionDB:
|
||||
try:
|
||||
msg["tool_calls"] = json.loads(row["tool_calls"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
|
||||
msg["tool_calls"] = []
|
||||
pass
|
||||
# Restore reasoning fields on assistant messages so providers
|
||||
# that replay reasoning (OpenRouter, OpenAI, Nous) receive
|
||||
# coherent multi-turn reasoning context.
|
||||
@@ -985,14 +983,12 @@ class SessionDB:
|
||||
try:
|
||||
msg["reasoning_details"] = json.loads(row["reasoning_details"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Failed to deserialize reasoning_details, falling back to None")
|
||||
msg["reasoning_details"] = None
|
||||
pass
|
||||
if row["codex_reasoning_items"]:
|
||||
try:
|
||||
msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
|
||||
msg["codex_reasoning_items"] = None
|
||||
pass
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
|
||||
@@ -63,17 +63,6 @@ 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 = [
|
||||
|
||||
+32
-168
@@ -500,8 +500,6 @@ 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,
|
||||
@@ -624,7 +622,6 @@ 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._reasoning_deltas_fired = False # Set by _fire_reasoning_delta, reset per API call
|
||||
@@ -664,8 +661,6 @@ 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.
|
||||
@@ -794,7 +789,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.30.0",
|
||||
"User-Agent": "KimiCLI/1.3",
|
||||
}
|
||||
elif "portal.qwen.ai" in effective_base.lower():
|
||||
client_kwargs["default_headers"] = _qwen_portal_headers()
|
||||
@@ -1465,14 +1460,7 @@ 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:
|
||||
@@ -1498,17 +1486,6 @@ 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.
|
||||
|
||||
@@ -3347,7 +3324,7 @@ class AIAgent:
|
||||
allowed_keys = {
|
||||
"model", "instructions", "input", "tools", "store",
|
||||
"reasoning", "include", "max_output_tokens", "temperature",
|
||||
"tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier",
|
||||
"tool_choice", "parallel_tool_calls", "prompt_cache_key",
|
||||
}
|
||||
normalized: Dict[str, Any] = {
|
||||
"model": model,
|
||||
@@ -3365,9 +3342,6 @@ 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")
|
||||
@@ -4181,7 +4155,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.30.0"}
|
||||
self._client_kwargs["default_headers"] = {"User-Agent": "KimiCLI/1.3"}
|
||||
elif "portal.qwen.ai" in normalized:
|
||||
self._client_kwargs["default_headers"] = _qwen_portal_headers()
|
||||
else:
|
||||
@@ -4433,17 +4407,7 @@ 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", 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_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 60.0))
|
||||
stream_kwargs = {
|
||||
**api_kwargs,
|
||||
"stream": True,
|
||||
@@ -4601,31 +4565,20 @@ 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=arguments,
|
||||
arguments=tc["function"]["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,
|
||||
@@ -4636,7 +4589,7 @@ class AIAgent:
|
||||
mock_choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=mock_message,
|
||||
finish_reason=effective_finish_reason,
|
||||
finish_reason=finish_reason or "stop",
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id="stream-" + str(uuid.uuid4()),
|
||||
@@ -5466,7 +5419,6 @@ 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":
|
||||
@@ -5482,10 +5434,6 @@ 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"
|
||||
@@ -5523,10 +5471,7 @@ class AIAgent:
|
||||
elif not is_github_responses:
|
||||
kwargs["include"] = []
|
||||
|
||||
if self.request_overrides:
|
||||
kwargs.update(self.request_overrides)
|
||||
|
||||
if self.max_tokens is not None and not is_codex_backend:
|
||||
if self.max_tokens is not None:
|
||||
kwargs["max_output_tokens"] = self.max_tokens
|
||||
|
||||
return kwargs
|
||||
@@ -5621,20 +5566,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() 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.
|
||||
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.
|
||||
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 the proxy pick its default
|
||||
pass # fail open — let OpenRouter pick its default
|
||||
|
||||
extra_body = {}
|
||||
|
||||
@@ -5697,11 +5642,6 @@ 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:
|
||||
@@ -6407,7 +6347,7 @@ class AIAgent:
|
||||
|
||||
# Start spinner for CLI mode (skip when TUI handles tool progress)
|
||||
spinner = None
|
||||
if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
if self.quiet_mode and not self.tool_progress_callback 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()
|
||||
@@ -6457,7 +6397,7 @@ class AIAgent:
|
||||
logging.debug(f"Tool result ({len(function_result)} chars): {function_result}")
|
||||
|
||||
# Print cute message per tool
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
if self.quiet_mode:
|
||||
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:
|
||||
@@ -6614,7 +6554,7 @@ class AIAgent:
|
||||
store=self._todo_store,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
if self.quiet_mode:
|
||||
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:
|
||||
@@ -6629,7 +6569,7 @@ class AIAgent:
|
||||
current_session_id=self.session_id,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
if self.quiet_mode:
|
||||
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")
|
||||
@@ -6642,7 +6582,7 @@ class AIAgent:
|
||||
store=self._memory_store,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
if self.quiet_mode:
|
||||
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
|
||||
@@ -6652,7 +6592,7 @@ class AIAgent:
|
||||
callback=self.clarify_callback,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if self._should_emit_quiet_tool_messages():
|
||||
if self.quiet_mode:
|
||||
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
|
||||
@@ -6663,7 +6603,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._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
if self.quiet_mode and not self.tool_progress_callback 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()
|
||||
@@ -6685,13 +6625,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._should_emit_quiet_tool_messages():
|
||||
elif self.quiet_mode:
|
||||
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._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
if self.quiet_mode and not self.tool_progress_callback:
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
@@ -6709,11 +6649,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._should_emit_quiet_tool_messages():
|
||||
elif self.quiet_mode:
|
||||
self._vprint(f" {cute_msg}")
|
||||
elif self.quiet_mode:
|
||||
spinner = None
|
||||
if self._should_emit_quiet_tool_messages() and self._should_start_quiet_spinner():
|
||||
if not self.tool_progress_callback:
|
||||
face = random.choice(KawaiiSpinner.KAWAII_WAITING)
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
@@ -6736,7 +6676,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)
|
||||
elif self._should_emit_quiet_tool_messages():
|
||||
else:
|
||||
self._vprint(f" {cute_msg}")
|
||||
else:
|
||||
try:
|
||||
@@ -7360,7 +7300,6 @@ 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
|
||||
@@ -7829,11 +7768,9 @@ 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 = []
|
||||
@@ -7843,11 +7780,9 @@ class AIAgent:
|
||||
_trunc_content = "\n".join(_text_parts) if _text_parts else None
|
||||
|
||||
_thinking_exhausted = (
|
||||
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
|
||||
)
|
||||
)
|
||||
_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 = (
|
||||
@@ -7923,34 +7858,6 @@ 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")
|
||||
@@ -8263,33 +8170,7 @@ 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)
|
||||
@@ -8345,10 +8226,6 @@ 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 "
|
||||
@@ -8406,10 +8283,6 @@ 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...")
|
||||
@@ -8528,10 +8401,6 @@ 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:
|
||||
@@ -9139,11 +9008,6 @@ 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
|
||||
|
||||
+35
-250
@@ -2,8 +2,8 @@
|
||||
# ============================================================================
|
||||
# Hermes Agent Installer
|
||||
# ============================================================================
|
||||
# Installation script for Linux, macOS, and Android/Termux.
|
||||
# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
|
||||
# Installation script for Linux and macOS.
|
||||
# Uses uv for fast Python provisioning and package management.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
@@ -117,36 +117,6 @@ 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
|
||||
# ============================================================================
|
||||
@@ -154,17 +124,12 @@ get_hermes_command_path() {
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux*)
|
||||
if is_termux; then
|
||||
OS="android"
|
||||
DISTRO="termux"
|
||||
OS="linux"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
DISTRO="$ID"
|
||||
else
|
||||
OS="linux"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
DISTRO="$ID"
|
||||
else
|
||||
DISTRO="unknown"
|
||||
fi
|
||||
DISTRO="unknown"
|
||||
fi
|
||||
;;
|
||||
Darwin*)
|
||||
@@ -193,12 +158,6 @@ 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
|
||||
@@ -250,25 +209,6 @@ 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
|
||||
@@ -303,17 +243,6 @@ 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
|
||||
@@ -333,9 +262,6 @@ check_git() {
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
android)
|
||||
log_info " pkg install git"
|
||||
;;
|
||||
macos)
|
||||
log_info " xcode-select --install"
|
||||
log_info " Or: brew install git"
|
||||
@@ -364,29 +290,11 @@ check_node() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
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
|
||||
log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
|
||||
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
|
||||
@@ -505,30 +413,6 @@ 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
|
||||
@@ -666,9 +550,6 @@ 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
|
||||
}
|
||||
@@ -765,19 +646,6 @@ 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
|
||||
@@ -794,46 +662,6 @@ 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"
|
||||
@@ -915,35 +743,19 @@ 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."
|
||||
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
|
||||
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
return 0
|
||||
fi
|
||||
|
||||
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
|
||||
# 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"
|
||||
|
||||
# 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 "^$command_link_dir$"; then
|
||||
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then
|
||||
SHELL_CONFIGS=()
|
||||
LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
|
||||
case "$LOGIN_SHELL" in
|
||||
@@ -989,7 +801,7 @@ setup_path() {
|
||||
fi
|
||||
|
||||
# Export for current session so hermes works immediately
|
||||
export PATH="$command_link_dir:$PATH"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
log_success "hermes command ready"
|
||||
}
|
||||
@@ -1066,13 +878,6 @@ 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"
|
||||
@@ -1187,7 +992,8 @@ maybe_start_gateway() {
|
||||
read -p "Pair WhatsApp now? [Y/n] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||
HERMES_CMD="$(get_hermes_command_path)"
|
||||
HERMES_CMD="$HOME/.local/bin/hermes"
|
||||
[ ! -x "$HERMES_CMD" ] && HERMES_CMD="hermes"
|
||||
$HERMES_CMD whatsapp || true
|
||||
fi
|
||||
else
|
||||
@@ -1201,17 +1007,16 @@ maybe_start_gateway() {
|
||||
fi
|
||||
|
||||
echo ""
|
||||
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
|
||||
read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r < /dev/tty
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||
HERMES_CMD="$(get_hermes_command_path)"
|
||||
HERMES_CMD="$HOME/.local/bin/hermes"
|
||||
if [ ! -x "$HERMES_CMD" ]; then
|
||||
HERMES_CMD="hermes"
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" != "termux" ] && command -v systemctl &> /dev/null; then
|
||||
if 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"
|
||||
@@ -1224,19 +1029,12 @@ maybe_start_gateway() {
|
||||
log_warn "Systemd install failed. You can start manually: hermes gateway"
|
||||
fi
|
||||
else
|
||||
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
|
||||
log_info "systemd not available — starting gateway in background..."
|
||||
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"
|
||||
@@ -1275,33 +1073,24 @@ print_success() {
|
||||
|
||||
echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}"
|
||||
echo ""
|
||||
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 ""
|
||||
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 -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 ""
|
||||
echo " source ~/.bashrc # or ~/.zshrc"
|
||||
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:"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
echo " pkg install nodejs"
|
||||
else
|
||||
echo " https://nodejs.org/en/download/"
|
||||
fi
|
||||
echo " https://nodejs.org/en/download/"
|
||||
echo -e "${NC}"
|
||||
fi
|
||||
|
||||
@@ -1310,11 +1099,7 @@ 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,"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
echo "install ripgrep: pkg install ripgrep"
|
||||
else
|
||||
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
|
||||
fi
|
||||
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
|
||||
echo -e "${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
+117
-209
@@ -3,17 +3,17 @@
|
||||
# Hermes Agent Setup Script
|
||||
# ============================================================================
|
||||
# Quick setup for developers who cloned the repo manually.
|
||||
# Uses uv for desktop/server setup and Python's stdlib venv + pip on Termux.
|
||||
# Uses uv for fast Python provisioning and package management.
|
||||
#
|
||||
# Usage:
|
||||
# ./setup-hermes.sh
|
||||
#
|
||||
# This script:
|
||||
# 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
|
||||
# 1. Installs uv if not present
|
||||
# 2. Creates a virtual environment with Python 3.11 via uv
|
||||
# 3. Installs all dependencies (main package + submodules)
|
||||
# 4. Creates .env from template (if not exists)
|
||||
# 5. Symlinks the 'hermes' CLI command into a user-facing bin dir
|
||||
# 5. Symlinks the 'hermes' CLI command into ~/.local/bin
|
||||
# 6. Runs the setup wizard (optional)
|
||||
# ============================================================================
|
||||
|
||||
@@ -31,26 +31,6 @@ 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 ""
|
||||
@@ -62,40 +42,36 @@ echo ""
|
||||
echo -e "${CYAN}→${NC} Checking for uv..."
|
||||
|
||||
UV_CMD=""
|
||||
if is_termux; then
|
||||
echo -e "${CYAN}→${NC} Termux detected — using Python's stdlib venv + pip instead of uv"
|
||||
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
|
||||
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
|
||||
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} Failed to install uv. Visit https://docs.astral.sh/uv/"
|
||||
echo -e "${RED}✗${NC} uv installed but not found. Add ~/.local/bin to PATH and retry."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗${NC} Failed to install uv. Visit https://docs.astral.sh/uv/"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -105,34 +81,16 @@ fi
|
||||
|
||||
echo -e "${CYAN}→${NC} Checking Python $PYTHON_VERSION..."
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
# ============================================================================
|
||||
@@ -146,16 +104,11 @@ if [ -d "venv" ]; then
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
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
|
||||
$UV_CMD venv venv --python "$PYTHON_VERSION"
|
||||
echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)"
|
||||
|
||||
# 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
|
||||
@@ -163,34 +116,19 @@ SETUP_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
||||
|
||||
echo -e "${CYAN}→${NC} Installing dependencies..."
|
||||
|
||||
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
|
||||
# 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"
|
||||
fi
|
||||
}
|
||||
else
|
||||
$UV_CMD pip install -e ".[all]" || $UV_CMD pip install -e "."
|
||||
echo -e "${GREEN}✓${NC} Dependencies installed"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
@@ -200,9 +138,7 @@ fi
|
||||
echo -e "${CYAN}→${NC} Installing optional submodules..."
|
||||
|
||||
# tinker-atropos (RL training backend)
|
||||
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
|
||||
if [ -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)"
|
||||
@@ -224,42 +160,34 @@ else
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||
INSTALLED=false
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
if [ "$INSTALLED" = true ]; then
|
||||
echo -e "${GREEN}✓${NC} ripgrep installed"
|
||||
else
|
||||
echo -e "${YELLOW}⚠${NC} Auto-install failed. Install options:"
|
||||
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 " sudo apt install ripgrep # Debian/Ubuntu"
|
||||
echo " brew install ripgrep # macOS"
|
||||
echo " cargo install ripgrep # With Rust (no sudo)"
|
||||
echo " https://github.com/BurntSushi/ripgrep#installation"
|
||||
fi
|
||||
fi
|
||||
@@ -279,56 +207,49 @@ else
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# PATH setup — symlink hermes into a user-facing bin dir
|
||||
# PATH setup — symlink hermes into ~/.local/bin
|
||||
# ============================================================================
|
||||
|
||||
echo -e "${CYAN}→${NC} Setting up hermes command..."
|
||||
|
||||
HERMES_BIN="$SCRIPT_DIR/venv/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"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_BIN" "$HOME/.local/bin/hermes"
|
||||
echo -e "${GREEN}✓${NC} Symlinked hermes → ~/.local/bin/hermes"
|
||||
|
||||
if is_termux; then
|
||||
export PATH="$COMMAND_LINK_DIR:$PATH"
|
||||
echo -e "${GREEN}✓${NC} $COMMAND_LINK_DISPLAY_DIR is already on PATH in Termux"
|
||||
# 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"
|
||||
else
|
||||
# Determine the appropriate shell config file
|
||||
SHELL_CONFIG=""
|
||||
if [[ "$SHELL" == *"zsh"* ]]; then
|
||||
# Fallback to checking existing files
|
||||
if [ -f "$HOME/.zshrc" ]; then
|
||||
SHELL_CONFIG="$HOME/.zshrc"
|
||||
elif [[ "$SHELL" == *"bash"* ]]; then
|
||||
elif [ -f "$HOME/.bashrc" ]; then
|
||||
SHELL_CONFIG="$HOME/.bashrc"
|
||||
[ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile"
|
||||
else
|
||||
# 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
|
||||
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
|
||||
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 on PATH"
|
||||
echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG"
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -360,31 +281,18 @@ echo -e "${GREEN}✓ Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
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 " 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 ""
|
||||
echo "Other commands:"
|
||||
echo " hermes status # Check configuration"
|
||||
if is_termux; then
|
||||
echo " hermes gateway # Run gateway in foreground"
|
||||
else
|
||||
echo " hermes gateway install # Install gateway service (messaging + cron)"
|
||||
fi
|
||||
echo " hermes gateway install # Install gateway service (messaging + cron)"
|
||||
echo " hermes cron list # View scheduled jobs"
|
||||
echo " hermes doctor # Diagnose issues"
|
||||
echo ""
|
||||
|
||||
@@ -250,7 +250,7 @@ Type these during an interactive chat session.
|
||||
/model [name] Show or change model
|
||||
/provider Show provider info
|
||||
/personality [name] Set personality
|
||||
/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide)
|
||||
/reasoning [level] Set reasoning (none|low|medium|high|xhigh|show|hide)
|
||||
/verbose Cycle: off → new → all → verbose
|
||||
/voice [on|off|tts] Voice mode
|
||||
/yolo Toggle approval bypass
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: google-workspace
|
||||
description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via gws CLI (googleworkspace/cli). Uses OAuth2 with automatic token refresh via bridge script. Requires gws binary.
|
||||
version: 2.0.0
|
||||
description: Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via Python. Uses OAuth2 with automatic token refresh. No external binaries needed — runs entirely with Google's Python client libraries in the Hermes venv.
|
||||
version: 1.0.0
|
||||
author: Nous Research
|
||||
license: MIT
|
||||
required_credential_files:
|
||||
@@ -11,25 +11,14 @@ required_credential_files:
|
||||
description: Google OAuth2 client credentials (downloaded from Google Cloud Console)
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth, gws]
|
||||
tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]
|
||||
homepage: https://github.com/NousResearch/hermes-agent
|
||||
related_skills: [himalaya]
|
||||
---
|
||||
|
||||
# Google Workspace
|
||||
|
||||
Gmail, Calendar, Drive, Contacts, Sheets, and Docs — powered by `gws` (Google's official Rust CLI). The skill provides a backward-compatible Python wrapper that handles OAuth token refresh and delegates to `gws`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
google_api.py → gws_bridge.py → gws CLI
|
||||
(argparse compat) (token refresh) (Google APIs)
|
||||
```
|
||||
|
||||
- `setup.py` handles OAuth2 (headless-compatible, works on CLI/Telegram/Discord)
|
||||
- `gws_bridge.py` refreshes the Hermes token and injects it into `gws` via `GOOGLE_WORKSPACE_CLI_TOKEN`
|
||||
- `google_api.py` provides the same CLI interface as v1 but delegates to `gws`
|
||||
Gmail, Calendar, Drive, Contacts, Sheets, and Docs — all through Python scripts in this skill. No external binaries to install.
|
||||
|
||||
## References
|
||||
|
||||
@@ -38,22 +27,7 @@ google_api.py → gws_bridge.py → gws CLI
|
||||
## Scripts
|
||||
|
||||
- `scripts/setup.py` — OAuth2 setup (run once to authorize)
|
||||
- `scripts/gws_bridge.py` — Token refresh bridge to gws CLI
|
||||
- `scripts/google_api.py` — Backward-compatible API wrapper (delegates to gws)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install `gws`:
|
||||
|
||||
```bash
|
||||
cargo install google-workspace-cli
|
||||
# or via npm (recommended, downloads prebuilt binary):
|
||||
npm install -g @googleworkspace/cli
|
||||
# or via Homebrew:
|
||||
brew install googleworkspace-cli
|
||||
```
|
||||
|
||||
Verify: `gws --version`
|
||||
- `scripts/google_api.py` — API wrapper CLI (agent uses this for all operations)
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
@@ -82,29 +56,42 @@ If it prints `AUTHENTICATED`, skip to Usage — setup is already done.
|
||||
|
||||
### Step 1: Triage — ask the user what they need
|
||||
|
||||
Before starting OAuth setup, ask the user TWO questions:
|
||||
|
||||
**Question 1: "What Google services do you need? Just email, or also
|
||||
Calendar/Drive/Sheets/Docs?"**
|
||||
|
||||
- **Email only** → Use the `himalaya` skill instead — simpler setup.
|
||||
- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue below.
|
||||
- **Email only** → They don't need this skill at all. Use the `himalaya` skill
|
||||
instead — it works with a Gmail App Password (Settings → Security → App
|
||||
Passwords) and takes 2 minutes to set up. No Google Cloud project needed.
|
||||
Load the himalaya skill and follow its setup instructions.
|
||||
|
||||
**Partial scopes**: Users can authorize only a subset of services. The setup
|
||||
script accepts partial scopes and warns about missing ones.
|
||||
- **Calendar, Drive, Sheets, Docs (or email + these)** → Continue with this
|
||||
skill's OAuth setup below.
|
||||
|
||||
**Question 2: "Does your Google account use Advanced Protection?"**
|
||||
**Question 2: "Does your Google account use Advanced Protection (hardware
|
||||
security keys required to sign in)? If you're not sure, you probably don't
|
||||
— it's something you would have explicitly enrolled in."**
|
||||
|
||||
- **No / Not sure** → Normal setup.
|
||||
- **Yes** → Workspace admin must add the OAuth client ID to allowed apps first.
|
||||
- **No / Not sure** → Normal setup. Continue below.
|
||||
- **Yes** → Their Workspace admin must add the OAuth client ID to the org's
|
||||
allowed apps list before Step 4 will work. Let them know upfront.
|
||||
|
||||
### Step 2: Create OAuth credentials (one-time, ~5 minutes)
|
||||
|
||||
Tell the user:
|
||||
|
||||
> You need a Google Cloud OAuth client. This is a one-time setup:
|
||||
>
|
||||
> 1. Go to https://console.cloud.google.com/apis/credentials
|
||||
> 2. Create a project (or use an existing one)
|
||||
> 3. Enable the APIs you need (Gmail, Calendar, Drive, Sheets, Docs, People)
|
||||
> 4. Credentials → Create Credentials → OAuth 2.0 Client ID → Desktop app
|
||||
> 5. Download JSON and tell me the file path
|
||||
> 3. Click "Enable APIs" and enable: Gmail API, Google Calendar API,
|
||||
> Google Drive API, Google Sheets API, Google Docs API, People API
|
||||
> 4. Go to Credentials → Create Credentials → OAuth 2.0 Client ID
|
||||
> 5. Application type: "Desktop app" → Create
|
||||
> 6. Click "Download JSON" and tell me the file path
|
||||
|
||||
Once they provide the path:
|
||||
|
||||
```bash
|
||||
$GSETUP --client-secret /path/to/client_secret.json
|
||||
@@ -116,10 +103,20 @@ $GSETUP --client-secret /path/to/client_secret.json
|
||||
$GSETUP --auth-url
|
||||
```
|
||||
|
||||
Send the URL to the user. After authorizing, they paste back the redirect URL or code.
|
||||
This prints a URL. **Send the URL to the user** and tell them:
|
||||
|
||||
> Open this link in your browser, sign in with your Google account, and
|
||||
> authorize access. After authorizing, you'll be redirected to a page that
|
||||
> may show an error — that's expected. Copy the ENTIRE URL from your
|
||||
> browser's address bar and paste it back to me.
|
||||
|
||||
### Step 4: Exchange the code
|
||||
|
||||
The user will paste back either a URL like `http://localhost:1/?code=4/0A...&scope=...`
|
||||
or just the code string. Either works. The `--auth-url` step stores a temporary
|
||||
pending OAuth session locally so `--auth-code` can complete the PKCE exchange
|
||||
later, even on headless systems:
|
||||
|
||||
```bash
|
||||
$GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED"
|
||||
```
|
||||
@@ -130,11 +127,18 @@ $GSETUP --auth-code "THE_URL_OR_CODE_THE_USER_PASTED"
|
||||
$GSETUP --check
|
||||
```
|
||||
|
||||
Should print `AUTHENTICATED`. Token refreshes automatically from now on.
|
||||
Should print `AUTHENTICATED`. Setup is complete — token refreshes automatically from now on.
|
||||
|
||||
### Notes
|
||||
|
||||
- Token is stored at `google_token.json` under the active profile's `HERMES_HOME` and auto-refreshes.
|
||||
- Pending OAuth session state/verifier are stored temporarily at `google_oauth_pending.json` under the active profile's `HERMES_HOME` until exchange completes.
|
||||
- Hermes now refuses to overwrite a full Google Workspace token with a narrower re-auth token missing Gmail scopes, so one profile's partial consent cannot silently break email actions later.
|
||||
- To revoke: `$GSETUP --revoke`
|
||||
|
||||
## Usage
|
||||
|
||||
All commands go through the API script:
|
||||
All commands go through the API script. Set `GAPI` as a shorthand:
|
||||
|
||||
```bash
|
||||
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||
@@ -149,21 +153,40 @@ GAPI="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/google_api.py"
|
||||
### Gmail
|
||||
|
||||
```bash
|
||||
# Search (returns JSON array with id, from, subject, date, snippet)
|
||||
$GAPI gmail search "is:unread" --max 10
|
||||
$GAPI gmail search "from:boss@company.com newer_than:1d"
|
||||
$GAPI gmail search "has:attachment filename:pdf newer_than:7d"
|
||||
|
||||
# Read full message (returns JSON with body text)
|
||||
$GAPI gmail get MESSAGE_ID
|
||||
|
||||
# Send
|
||||
$GAPI gmail send --to user@example.com --subject "Hello" --body "Message text"
|
||||
$GAPI gmail send --to user@example.com --subject "Report" --body "<h1>Q4</h1>" --html
|
||||
$GAPI gmail send --to user@example.com --subject "Report" --body "<h1>Q4</h1><p>Details...</p>" --html
|
||||
|
||||
# Reply (automatically threads and sets In-Reply-To)
|
||||
$GAPI gmail reply MESSAGE_ID --body "Thanks, that works for me."
|
||||
|
||||
# Labels
|
||||
$GAPI gmail labels
|
||||
$GAPI gmail modify MESSAGE_ID --add-labels LABEL_ID
|
||||
$GAPI gmail modify MESSAGE_ID --remove-labels UNREAD
|
||||
```
|
||||
|
||||
### Calendar
|
||||
|
||||
```bash
|
||||
# List events (defaults to next 7 days)
|
||||
$GAPI calendar list
|
||||
$GAPI calendar create --summary "Standup" --start 2026-03-01T10:00:00+01:00 --end 2026-03-01T10:30:00+01:00
|
||||
$GAPI calendar create --summary "Review" --start ... --end ... --attendees "alice@co.com,bob@co.com"
|
||||
$GAPI calendar list --start 2026-03-01T00:00:00Z --end 2026-03-07T23:59:59Z
|
||||
|
||||
# Create event (ISO 8601 with timezone required)
|
||||
$GAPI calendar create --summary "Team Standup" --start 2026-03-01T10:00:00-06:00 --end 2026-03-01T10:30:00-06:00
|
||||
$GAPI calendar create --summary "Lunch" --start 2026-03-01T12:00:00Z --end 2026-03-01T13:00:00Z --location "Cafe"
|
||||
$GAPI calendar create --summary "Review" --start 2026-03-01T14:00:00Z --end 2026-03-01T15:00:00Z --attendees "alice@co.com,bob@co.com"
|
||||
|
||||
# Delete event
|
||||
$GAPI calendar delete EVENT_ID
|
||||
```
|
||||
|
||||
@@ -183,8 +206,13 @@ $GAPI contacts list --max 20
|
||||
### Sheets
|
||||
|
||||
```bash
|
||||
# Read
|
||||
$GAPI sheets get SHEET_ID "Sheet1!A1:D10"
|
||||
|
||||
# Write
|
||||
$GAPI sheets update SHEET_ID "Sheet1!A1:B2" --values '[["Name","Score"],["Alice","95"]]'
|
||||
|
||||
# Append rows
|
||||
$GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]'
|
||||
```
|
||||
|
||||
@@ -194,52 +222,37 @@ $GAPI sheets append SHEET_ID "Sheet1!A:C" --values '[["new","row","data"]]'
|
||||
$GAPI docs get DOC_ID
|
||||
```
|
||||
|
||||
### Direct gws access (advanced)
|
||||
|
||||
For operations not covered by the wrapper, use `gws_bridge.py` directly:
|
||||
|
||||
```bash
|
||||
GBRIDGE="$PYTHON_BIN $GWORKSPACE_SKILL_DIR/scripts/gws_bridge.py"
|
||||
$GBRIDGE calendar +agenda --today --format table
|
||||
$GBRIDGE gmail +triage --labels --format json
|
||||
$GBRIDGE drive +upload ./report.pdf
|
||||
$GBRIDGE sheets +read --spreadsheet SHEET_ID --range "Sheet1!A1:D10"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
All commands return JSON via `gws --format json`. Key output shapes:
|
||||
All commands return JSON. Parse with `jq` or read directly. Key fields:
|
||||
|
||||
- **Gmail search/triage**: Array of message summaries (sender, subject, date, snippet)
|
||||
- **Gmail get/read**: Message object with headers and body text
|
||||
- **Gmail send/reply**: Confirmation with message ID
|
||||
- **Calendar list/agenda**: Array of event objects (summary, start, end, location)
|
||||
- **Calendar create**: Confirmation with event ID and htmlLink
|
||||
- **Drive search**: Array of file objects (id, name, mimeType, webViewLink)
|
||||
- **Sheets get/read**: 2D array of cell values
|
||||
- **Docs get**: Full document JSON (use `body.content` for text extraction)
|
||||
- **Contacts list**: Array of person objects with names, emails, phones
|
||||
|
||||
Parse output with `jq` or read JSON directly.
|
||||
- **Gmail search**: `[{id, threadId, from, to, subject, date, snippet, labels}]`
|
||||
- **Gmail get**: `{id, threadId, from, to, subject, date, labels, body}`
|
||||
- **Gmail send/reply**: `{status: "sent", id, threadId}`
|
||||
- **Calendar list**: `[{id, summary, start, end, location, description, htmlLink}]`
|
||||
- **Calendar create**: `{status: "created", id, summary, htmlLink}`
|
||||
- **Drive search**: `[{id, name, mimeType, modifiedTime, webViewLink}]`
|
||||
- **Contacts list**: `[{name, emails: [...], phones: [...]}]`
|
||||
- **Sheets get**: `[[cell, cell, ...], ...]`
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Never send email or create/delete events without confirming with the user first.**
|
||||
2. **Check auth before first use** — run `setup.py --check`.
|
||||
3. **Use the Gmail search syntax reference** for complex queries.
|
||||
4. **Calendar times must include timezone** — ISO 8601 with offset or UTC.
|
||||
5. **Respect rate limits** — avoid rapid-fire sequential API calls.
|
||||
1. **Never send email or create/delete events without confirming with the user first.** Show the draft content and ask for approval.
|
||||
2. **Check auth before first use** — run `setup.py --check`. If it fails, guide the user through setup.
|
||||
3. **Use the Gmail search syntax reference** for complex queries — load it with `skill_view("google-workspace", file_path="references/gmail-search-syntax.md")`.
|
||||
4. **Calendar times must include timezone** — always use ISO 8601 with offset (e.g., `2026-03-01T10:00:00-06:00`) or UTC (`Z`).
|
||||
5. **Respect rate limits** — avoid rapid-fire sequential API calls. Batch reads when possible.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
|---------|-----|
|
||||
| `NOT_AUTHENTICATED` | Run setup Steps 2-5 |
|
||||
| `REFRESH_FAILED` | Token revoked — redo Steps 3-5 |
|
||||
| `gws: command not found` | Install: `npm install -g @googleworkspace/cli` |
|
||||
| `HttpError 403` | Missing scope — `$GSETUP --revoke` then redo Steps 3-5 |
|
||||
| `HttpError 403: Access Not Configured` | Enable API in Google Cloud Console |
|
||||
| Advanced Protection blocks auth | Admin must allowlist the OAuth client ID |
|
||||
| `NOT_AUTHENTICATED` | Run setup Steps 2-5 above |
|
||||
| `REFRESH_FAILED` | Token revoked or expired — redo Steps 3-5 |
|
||||
| `HttpError 403: Insufficient Permission` | Missing API scope — `$GSETUP --revoke` then redo Steps 3-5 |
|
||||
| `HttpError 403: Access Not Configured` | API not enabled — user needs to enable it in Google Cloud Console |
|
||||
| `ModuleNotFoundError` | Run `$GSETUP --install-deps` |
|
||||
| Advanced Protection blocks auth | Workspace admin must allowlist the OAuth client ID |
|
||||
|
||||
## Revoking Access
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Google Workspace API CLI for Hermes Agent.
|
||||
|
||||
Thin wrapper that delegates to gws (googleworkspace/cli) via gws_bridge.py.
|
||||
Maintains the same CLI interface for backward compatibility with Hermes skills.
|
||||
A thin CLI wrapper around Google's Python client libraries.
|
||||
Authenticates using the token stored by setup.py.
|
||||
|
||||
Usage:
|
||||
python google_api.py gmail search "is:unread" [--max 10]
|
||||
python google_api.py gmail get MESSAGE_ID
|
||||
python google_api.py gmail send --to user@example.com --subject "Hi" --body "Hello"
|
||||
python google_api.py gmail reply MESSAGE_ID --body "Thanks"
|
||||
python google_api.py calendar list [--start DATE] [--end DATE] [--calendar primary]
|
||||
python google_api.py calendar list [--from DATE] [--to DATE] [--calendar primary]
|
||||
python google_api.py calendar create --summary "Meeting" --start DATETIME --end DATETIME
|
||||
python google_api.py calendar delete EVENT_ID
|
||||
python google_api.py drive search "budget report" [--max 10]
|
||||
python google_api.py contacts list [--max 20]
|
||||
python google_api.py sheets get SHEET_ID RANGE
|
||||
@@ -21,193 +20,386 @@ Usage:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.mime.text import MIMEText
|
||||
from pathlib import Path
|
||||
|
||||
BRIDGE = Path(__file__).parent / "gws_bridge.py"
|
||||
PYTHON = sys.executable
|
||||
try:
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
except ModuleNotFoundError:
|
||||
HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if HERMES_AGENT_ROOT.exists():
|
||||
sys.path.insert(0, str(HERMES_AGENT_ROOT))
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
|
||||
HERMES_HOME = get_hermes_home()
|
||||
TOKEN_PATH = HERMES_HOME / "google_token.json"
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents.readonly",
|
||||
]
|
||||
|
||||
|
||||
def gws(*args: str) -> None:
|
||||
"""Call gws via the bridge and exit with its return code."""
|
||||
result = subprocess.run(
|
||||
[PYTHON, str(BRIDGE)] + list(args),
|
||||
env={**os.environ, "HERMES_HOME": os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))},
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
def _missing_scopes() -> list[str]:
|
||||
try:
|
||||
payload = json.loads(TOKEN_PATH.read_text())
|
||||
except Exception:
|
||||
return []
|
||||
raw = payload.get("scopes") or payload.get("scope")
|
||||
if not raw:
|
||||
return []
|
||||
granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}
|
||||
return sorted(scope for scope in SCOPES if scope not in granted)
|
||||
|
||||
|
||||
# -- Gmail --
|
||||
def get_credentials():
|
||||
"""Load and refresh credentials from token file."""
|
||||
if not TOKEN_PATH.exists():
|
||||
print("Not authenticated. Run the setup script first:", file=sys.stderr)
|
||||
print(f" python {Path(__file__).parent / 'setup.py'}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
TOKEN_PATH.write_text(creds.to_json())
|
||||
if not creds.valid:
|
||||
print("Token is invalid. Re-run setup.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
missing_scopes = _missing_scopes()
|
||||
if missing_scopes:
|
||||
print(
|
||||
"Token is valid but missing Google Workspace scopes required by this skill.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for scope in missing_scopes:
|
||||
print(f" - {scope}", file=sys.stderr)
|
||||
print(
|
||||
f"Re-run setup.py from the active Hermes profile ({display_hermes_home()}) to restore full access.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return creds
|
||||
|
||||
|
||||
def build_service(api, version):
|
||||
from googleapiclient.discovery import build
|
||||
return build(api, version, credentials=get_credentials())
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Gmail
|
||||
# =========================================================================
|
||||
|
||||
def gmail_search(args):
|
||||
cmd = ["gmail", "+triage", "--query", args.query, "--max", str(args.max), "--format", "json"]
|
||||
gws(*cmd)
|
||||
service = build_service("gmail", "v1")
|
||||
results = service.users().messages().list(
|
||||
userId="me", q=args.query, maxResults=args.max
|
||||
).execute()
|
||||
messages = results.get("messages", [])
|
||||
if not messages:
|
||||
print("No messages found.")
|
||||
return
|
||||
|
||||
output = []
|
||||
for msg_meta in messages:
|
||||
msg = service.users().messages().get(
|
||||
userId="me", id=msg_meta["id"], format="metadata",
|
||||
metadataHeaders=["From", "To", "Subject", "Date"],
|
||||
).execute()
|
||||
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
|
||||
output.append({
|
||||
"id": msg["id"],
|
||||
"threadId": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"snippet": msg.get("snippet", ""),
|
||||
"labels": msg.get("labelIds", []),
|
||||
})
|
||||
print(json.dumps(output, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def gmail_get(args):
|
||||
gws("gmail", "+read", "--id", args.message_id, "--headers", "--format", "json")
|
||||
service = build_service("gmail", "v1")
|
||||
msg = service.users().messages().get(
|
||||
userId="me", id=args.message_id, format="full"
|
||||
).execute()
|
||||
|
||||
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
|
||||
|
||||
# Extract body text
|
||||
body = ""
|
||||
payload = msg.get("payload", {})
|
||||
if payload.get("body", {}).get("data"):
|
||||
body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="replace")
|
||||
elif payload.get("parts"):
|
||||
for part in payload["parts"]:
|
||||
if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"):
|
||||
body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace")
|
||||
break
|
||||
if not body:
|
||||
for part in payload["parts"]:
|
||||
if part.get("mimeType") == "text/html" and part.get("body", {}).get("data"):
|
||||
body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace")
|
||||
break
|
||||
|
||||
result = {
|
||||
"id": msg["id"],
|
||||
"threadId": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"labels": msg.get("labelIds", []),
|
||||
"body": body,
|
||||
}
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def gmail_send(args):
|
||||
cmd = ["gmail", "+send", "--to", args.to, "--subject", args.subject, "--body", args.body, "--format", "json"]
|
||||
service = build_service("gmail", "v1")
|
||||
message = MIMEText(args.body, "html" if args.html else "plain")
|
||||
message["to"] = args.to
|
||||
message["subject"] = args.subject
|
||||
if args.cc:
|
||||
cmd += ["--cc", args.cc]
|
||||
if args.html:
|
||||
cmd.append("--html")
|
||||
gws(*cmd)
|
||||
message["cc"] = args.cc
|
||||
|
||||
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
body = {"raw": raw}
|
||||
|
||||
if args.thread_id:
|
||||
body["threadId"] = args.thread_id
|
||||
|
||||
result = service.users().messages().send(userId="me", body=body).execute()
|
||||
print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2))
|
||||
|
||||
|
||||
def gmail_reply(args):
|
||||
gws("gmail", "+reply", "--message-id", args.message_id, "--body", args.body, "--format", "json")
|
||||
service = build_service("gmail", "v1")
|
||||
# Fetch original to get thread ID and headers
|
||||
original = service.users().messages().get(
|
||||
userId="me", id=args.message_id, format="metadata",
|
||||
metadataHeaders=["From", "Subject", "Message-ID"],
|
||||
).execute()
|
||||
headers = {h["name"]: h["value"] for h in original.get("payload", {}).get("headers", [])}
|
||||
|
||||
subject = headers.get("Subject", "")
|
||||
if not subject.startswith("Re:"):
|
||||
subject = f"Re: {subject}"
|
||||
|
||||
message = MIMEText(args.body)
|
||||
message["to"] = headers.get("From", "")
|
||||
message["subject"] = subject
|
||||
if headers.get("Message-ID"):
|
||||
message["In-Reply-To"] = headers["Message-ID"]
|
||||
message["References"] = headers["Message-ID"]
|
||||
|
||||
raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
||||
body = {"raw": raw, "threadId": original["threadId"]}
|
||||
|
||||
result = service.users().messages().send(userId="me", body=body).execute()
|
||||
print(json.dumps({"status": "sent", "id": result["id"], "threadId": result.get("threadId", "")}, indent=2))
|
||||
|
||||
|
||||
def gmail_labels(args):
|
||||
gws("gmail", "users", "labels", "list", "--params", json.dumps({"userId": "me"}), "--format", "json")
|
||||
service = build_service("gmail", "v1")
|
||||
results = service.users().labels().list(userId="me").execute()
|
||||
labels = [{"id": l["id"], "name": l["name"], "type": l.get("type", "")} for l in results.get("labels", [])]
|
||||
print(json.dumps(labels, indent=2))
|
||||
|
||||
|
||||
def gmail_modify(args):
|
||||
service = build_service("gmail", "v1")
|
||||
body = {}
|
||||
if args.add_labels:
|
||||
body["addLabelIds"] = args.add_labels.split(",")
|
||||
if args.remove_labels:
|
||||
body["removeLabelIds"] = args.remove_labels.split(",")
|
||||
gws(
|
||||
"gmail", "users", "messages", "modify",
|
||||
"--params", json.dumps({"userId": "me", "id": args.message_id}),
|
||||
"--json", json.dumps(body),
|
||||
"--format", "json",
|
||||
)
|
||||
result = service.users().messages().modify(userId="me", id=args.message_id, body=body).execute()
|
||||
print(json.dumps({"id": result["id"], "labels": result.get("labelIds", [])}, indent=2))
|
||||
|
||||
|
||||
# -- Calendar --
|
||||
# =========================================================================
|
||||
# Calendar
|
||||
# =========================================================================
|
||||
|
||||
def calendar_list(args):
|
||||
if args.start or args.end:
|
||||
# Specific date range — use raw Calendar API for precise timeMin/timeMax
|
||||
from datetime import datetime, timedelta, timezone as tz
|
||||
now = datetime.now(tz.utc)
|
||||
time_min = args.start or now.isoformat()
|
||||
time_max = args.end or (now + timedelta(days=7)).isoformat()
|
||||
gws(
|
||||
"calendar", "events", "list",
|
||||
"--params", json.dumps({
|
||||
"calendarId": args.calendar,
|
||||
"timeMin": time_min,
|
||||
"timeMax": time_max,
|
||||
"maxResults": args.max,
|
||||
"singleEvents": True,
|
||||
"orderBy": "startTime",
|
||||
}),
|
||||
"--format", "json",
|
||||
)
|
||||
else:
|
||||
# No date range — use +agenda helper (defaults to 7 days)
|
||||
cmd = ["calendar", "+agenda", "--days", "7", "--format", "json"]
|
||||
if args.calendar != "primary":
|
||||
cmd += ["--calendar", args.calendar]
|
||||
gws(*cmd)
|
||||
service = build_service("calendar", "v3")
|
||||
now = datetime.now(timezone.utc)
|
||||
time_min = args.start or now.isoformat()
|
||||
time_max = args.end or (now + timedelta(days=7)).isoformat()
|
||||
|
||||
# Ensure timezone info
|
||||
for val in [time_min, time_max]:
|
||||
if "T" in val and "Z" not in val and "+" not in val and "-" not in val[11:]:
|
||||
val += "Z"
|
||||
|
||||
results = service.events().list(
|
||||
calendarId=args.calendar, timeMin=time_min, timeMax=time_max,
|
||||
maxResults=args.max, singleEvents=True, orderBy="startTime",
|
||||
).execute()
|
||||
|
||||
events = []
|
||||
for e in results.get("items", []):
|
||||
events.append({
|
||||
"id": e["id"],
|
||||
"summary": e.get("summary", "(no title)"),
|
||||
"start": e.get("start", {}).get("dateTime", e.get("start", {}).get("date", "")),
|
||||
"end": e.get("end", {}).get("dateTime", e.get("end", {}).get("date", "")),
|
||||
"location": e.get("location", ""),
|
||||
"description": e.get("description", ""),
|
||||
"status": e.get("status", ""),
|
||||
"htmlLink": e.get("htmlLink", ""),
|
||||
})
|
||||
print(json.dumps(events, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def calendar_create(args):
|
||||
cmd = [
|
||||
"calendar", "+insert",
|
||||
"--summary", args.summary,
|
||||
"--start", args.start,
|
||||
"--end", args.end,
|
||||
"--format", "json",
|
||||
]
|
||||
service = build_service("calendar", "v3")
|
||||
event = {
|
||||
"summary": args.summary,
|
||||
"start": {"dateTime": args.start},
|
||||
"end": {"dateTime": args.end},
|
||||
}
|
||||
if args.location:
|
||||
cmd += ["--location", args.location]
|
||||
event["location"] = args.location
|
||||
if args.description:
|
||||
cmd += ["--description", args.description]
|
||||
event["description"] = args.description
|
||||
if args.attendees:
|
||||
for email in args.attendees.split(","):
|
||||
cmd += ["--attendee", email.strip()]
|
||||
if args.calendar != "primary":
|
||||
cmd += ["--calendar", args.calendar]
|
||||
gws(*cmd)
|
||||
event["attendees"] = [{"email": e.strip()} for e in args.attendees.split(",")]
|
||||
|
||||
result = service.events().insert(calendarId=args.calendar, body=event).execute()
|
||||
print(json.dumps({
|
||||
"status": "created",
|
||||
"id": result["id"],
|
||||
"summary": result.get("summary", ""),
|
||||
"htmlLink": result.get("htmlLink", ""),
|
||||
}, indent=2))
|
||||
|
||||
|
||||
def calendar_delete(args):
|
||||
gws(
|
||||
"calendar", "events", "delete",
|
||||
"--params", json.dumps({"calendarId": args.calendar, "eventId": args.event_id}),
|
||||
"--format", "json",
|
||||
)
|
||||
service = build_service("calendar", "v3")
|
||||
service.events().delete(calendarId=args.calendar, eventId=args.event_id).execute()
|
||||
print(json.dumps({"status": "deleted", "eventId": args.event_id}))
|
||||
|
||||
|
||||
# -- Drive --
|
||||
# =========================================================================
|
||||
# Drive
|
||||
# =========================================================================
|
||||
|
||||
def drive_search(args):
|
||||
query = args.query if args.raw_query else f"fullText contains '{args.query}'"
|
||||
gws(
|
||||
"drive", "files", "list",
|
||||
"--params", json.dumps({
|
||||
"q": query,
|
||||
"pageSize": args.max,
|
||||
"fields": "files(id,name,mimeType,modifiedTime,webViewLink)",
|
||||
}),
|
||||
"--format", "json",
|
||||
)
|
||||
service = build_service("drive", "v3")
|
||||
query = f"fullText contains '{args.query}'" if not args.raw_query else args.query
|
||||
results = service.files().list(
|
||||
q=query, pageSize=args.max, fields="files(id, name, mimeType, modifiedTime, webViewLink)",
|
||||
).execute()
|
||||
files = results.get("files", [])
|
||||
print(json.dumps(files, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
# -- Contacts --
|
||||
# =========================================================================
|
||||
# Contacts
|
||||
# =========================================================================
|
||||
|
||||
def contacts_list(args):
|
||||
gws(
|
||||
"people", "people", "connections", "list",
|
||||
"--params", json.dumps({
|
||||
"resourceName": "people/me",
|
||||
"pageSize": args.max,
|
||||
"personFields": "names,emailAddresses,phoneNumbers",
|
||||
}),
|
||||
"--format", "json",
|
||||
)
|
||||
service = build_service("people", "v1")
|
||||
results = service.people().connections().list(
|
||||
resourceName="people/me",
|
||||
pageSize=args.max,
|
||||
personFields="names,emailAddresses,phoneNumbers",
|
||||
).execute()
|
||||
contacts = []
|
||||
for person in results.get("connections", []):
|
||||
names = person.get("names", [{}])
|
||||
emails = person.get("emailAddresses", [])
|
||||
phones = person.get("phoneNumbers", [])
|
||||
contacts.append({
|
||||
"name": names[0].get("displayName", "") if names else "",
|
||||
"emails": [e.get("value", "") for e in emails],
|
||||
"phones": [p.get("value", "") for p in phones],
|
||||
})
|
||||
print(json.dumps(contacts, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
# -- Sheets --
|
||||
# =========================================================================
|
||||
# Sheets
|
||||
# =========================================================================
|
||||
|
||||
def sheets_get(args):
|
||||
gws(
|
||||
"sheets", "+read",
|
||||
"--spreadsheet", args.sheet_id,
|
||||
"--range", args.range,
|
||||
"--format", "json",
|
||||
)
|
||||
service = build_service("sheets", "v4")
|
||||
result = service.spreadsheets().values().get(
|
||||
spreadsheetId=args.sheet_id, range=args.range,
|
||||
).execute()
|
||||
print(json.dumps(result.get("values", []), indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def sheets_update(args):
|
||||
service = build_service("sheets", "v4")
|
||||
values = json.loads(args.values)
|
||||
gws(
|
||||
"sheets", "spreadsheets", "values", "update",
|
||||
"--params", json.dumps({
|
||||
"spreadsheetId": args.sheet_id,
|
||||
"range": args.range,
|
||||
"valueInputOption": "USER_ENTERED",
|
||||
}),
|
||||
"--json", json.dumps({"values": values}),
|
||||
"--format", "json",
|
||||
)
|
||||
body = {"values": values}
|
||||
result = service.spreadsheets().values().update(
|
||||
spreadsheetId=args.sheet_id, range=args.range,
|
||||
valueInputOption="USER_ENTERED", body=body,
|
||||
).execute()
|
||||
print(json.dumps({"updatedCells": result.get("updatedCells", 0), "updatedRange": result.get("updatedRange", "")}, indent=2))
|
||||
|
||||
|
||||
def sheets_append(args):
|
||||
service = build_service("sheets", "v4")
|
||||
values = json.loads(args.values)
|
||||
gws(
|
||||
"sheets", "+append",
|
||||
"--spreadsheet", args.sheet_id,
|
||||
"--json-values", json.dumps(values),
|
||||
"--format", "json",
|
||||
)
|
||||
body = {"values": values}
|
||||
result = service.spreadsheets().values().append(
|
||||
spreadsheetId=args.sheet_id, range=args.range,
|
||||
valueInputOption="USER_ENTERED", insertDataOption="INSERT_ROWS", body=body,
|
||||
).execute()
|
||||
print(json.dumps({"updatedCells": result.get("updates", {}).get("updatedCells", 0)}, indent=2))
|
||||
|
||||
|
||||
# -- Docs --
|
||||
# =========================================================================
|
||||
# Docs
|
||||
# =========================================================================
|
||||
|
||||
def docs_get(args):
|
||||
gws(
|
||||
"docs", "documents", "get",
|
||||
"--params", json.dumps({"documentId": args.doc_id}),
|
||||
"--format", "json",
|
||||
)
|
||||
service = build_service("docs", "v1")
|
||||
doc = service.documents().get(documentId=args.doc_id).execute()
|
||||
# Extract plain text from the document structure
|
||||
text_parts = []
|
||||
for element in doc.get("body", {}).get("content", []):
|
||||
paragraph = element.get("paragraph", {})
|
||||
for pe in paragraph.get("elements", []):
|
||||
text_run = pe.get("textRun", {})
|
||||
if text_run.get("content"):
|
||||
text_parts.append(text_run["content"])
|
||||
result = {
|
||||
"title": doc.get("title", ""),
|
||||
"documentId": doc.get("documentId", ""),
|
||||
"body": "".join(text_parts),
|
||||
}
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
# -- CLI parser (backward-compatible interface) --
|
||||
# =========================================================================
|
||||
# CLI parser
|
||||
# =========================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent (gws backend)")
|
||||
parser = argparse.ArgumentParser(description="Google Workspace API for Hermes Agent")
|
||||
sub = parser.add_subparsers(dest="service", required=True)
|
||||
|
||||
# --- Gmail ---
|
||||
@@ -229,7 +421,7 @@ def main():
|
||||
p.add_argument("--body", required=True)
|
||||
p.add_argument("--cc", default="")
|
||||
p.add_argument("--html", action="store_true", help="Send body as HTML")
|
||||
p.add_argument("--thread-id", default="", help="Thread ID (unused with gws, kept for compat)")
|
||||
p.add_argument("--thread-id", default="", help="Thread ID for threading")
|
||||
p.set_defaults(func=gmail_send)
|
||||
|
||||
p = gmail_sub.add_parser("reply")
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bridge between Hermes OAuth token and gws CLI.
|
||||
|
||||
Refreshes the token if expired, then executes gws with the valid access token.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_hermes_home() -> Path:
|
||||
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
def get_token_path() -> Path:
|
||||
return get_hermes_home() / "google_token.json"
|
||||
|
||||
|
||||
def refresh_token(token_data: dict) -> dict:
|
||||
"""Refresh the access token using the refresh token."""
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
params = urllib.parse.urlencode({
|
||||
"client_id": token_data["client_id"],
|
||||
"client_secret": token_data["client_secret"],
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"grant_type": "refresh_token",
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(token_data["token_uri"], data=params)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
print(f"ERROR: Token refresh failed (HTTP {e.code}): {body}", file=sys.stderr)
|
||||
print("Re-run setup.py to re-authenticate.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
token_data["token"] = result["access_token"]
|
||||
token_data["expiry"] = datetime.fromtimestamp(
|
||||
datetime.now(timezone.utc).timestamp() + result["expires_in"],
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
|
||||
get_token_path().write_text(json.dumps(token_data, indent=2))
|
||||
return token_data
|
||||
|
||||
|
||||
def get_valid_token() -> str:
|
||||
"""Return a valid access token, refreshing if needed."""
|
||||
token_path = get_token_path()
|
||||
if not token_path.exists():
|
||||
print("ERROR: No Google token found. Run setup.py --auth-url first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
token_data = json.loads(token_path.read_text())
|
||||
|
||||
expiry = token_data.get("expiry", "")
|
||||
if expiry:
|
||||
exp_dt = datetime.fromisoformat(expiry.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
if now >= exp_dt:
|
||||
token_data = refresh_token(token_data)
|
||||
|
||||
return token_data["token"]
|
||||
|
||||
|
||||
def main():
|
||||
"""Refresh token if needed, then exec gws with remaining args."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: gws_bridge.py <gws args...>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
access_token = get_valid_token()
|
||||
env = os.environ.copy()
|
||||
env["GOOGLE_WORKSPACE_CLI_TOKEN"] = access_token
|
||||
|
||||
result = subprocess.run(["gws"] + sys.argv[1:], env=env)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -23,7 +23,6 @@ Agent workflow:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -129,11 +128,7 @@ def check_auth():
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
try:
|
||||
# Don't pass scopes — user may have authorized only a subset.
|
||||
# Passing scopes forces google-auth to validate them on refresh,
|
||||
# which fails with invalid_scope if the token has fewer scopes
|
||||
# than requested.
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH))
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
except Exception as e:
|
||||
print(f"TOKEN_CORRUPT: {e}")
|
||||
return False
|
||||
@@ -142,9 +137,8 @@ def check_auth():
|
||||
if creds.valid:
|
||||
missing_scopes = _missing_scopes_from_payload(payload)
|
||||
if missing_scopes:
|
||||
print(f"AUTHENTICATED (partial): Token valid but missing {len(missing_scopes)} scopes:")
|
||||
for s in missing_scopes:
|
||||
print(f" - {s}")
|
||||
print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}")
|
||||
return False
|
||||
print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")
|
||||
return True
|
||||
|
||||
@@ -154,9 +148,8 @@ def check_auth():
|
||||
TOKEN_PATH.write_text(creds.to_json())
|
||||
missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))
|
||||
if missing_scopes:
|
||||
print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:")
|
||||
for s in missing_scopes:
|
||||
print(f" - {s}")
|
||||
print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}")
|
||||
return False
|
||||
print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -279,33 +272,16 @@ def exchange_auth_code(code: str):
|
||||
|
||||
_ensure_deps()
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Extract granted scopes from the callback URL if present
|
||||
if returned_state and "scope" in parse_qs(urlparse(code).query if isinstance(code, str) and code.startswith("http") else {}):
|
||||
granted_scopes = parse_qs(urlparse(code).query)["scope"][0].split()
|
||||
else:
|
||||
# Try to extract from code_or_url parameter
|
||||
if isinstance(code, str) and code.startswith("http"):
|
||||
params = parse_qs(urlparse(code).query)
|
||||
if "scope" in params:
|
||||
granted_scopes = params["scope"][0].split()
|
||||
else:
|
||||
granted_scopes = SCOPES
|
||||
else:
|
||||
granted_scopes = SCOPES
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(CLIENT_SECRET_PATH),
|
||||
scopes=granted_scopes,
|
||||
scopes=SCOPES,
|
||||
redirect_uri=pending_auth.get("redirect_uri", REDIRECT_URI),
|
||||
state=pending_auth["state"],
|
||||
code_verifier=pending_auth["code_verifier"],
|
||||
)
|
||||
|
||||
try:
|
||||
# Accept partial scopes — user may deselect some permissions in the consent screen
|
||||
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
|
||||
flow.fetch_token(code=code)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Token exchange failed: {e}")
|
||||
@@ -314,21 +290,11 @@ def exchange_auth_code(code: str):
|
||||
|
||||
creds = flow.credentials
|
||||
token_payload = json.loads(creds.to_json())
|
||||
|
||||
# Store only the scopes actually granted by the user, not what was requested.
|
||||
# creds.to_json() writes the requested scopes, which causes refresh to fail
|
||||
# with invalid_scope if the user only authorized a subset.
|
||||
actually_granted = list(creds.granted_scopes or []) if hasattr(creds, "granted_scopes") and creds.granted_scopes else []
|
||||
if actually_granted:
|
||||
token_payload["scopes"] = actually_granted
|
||||
elif granted_scopes != SCOPES:
|
||||
# granted_scopes was extracted from the callback URL
|
||||
token_payload["scopes"] = granted_scopes
|
||||
|
||||
missing_scopes = _missing_scopes_from_payload(token_payload)
|
||||
if missing_scopes:
|
||||
print(f"WARNING: Token missing some Google Workspace scopes: {', '.join(missing_scopes)}")
|
||||
print("Some services may not be available.")
|
||||
print(f"ERROR: Refusing to save incomplete Google Workspace token. {_format_missing_scopes(missing_scopes)}")
|
||||
print(f"Existing token at {TOKEN_PATH} was left unchanged.")
|
||||
sys.exit(1)
|
||||
|
||||
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))
|
||||
PENDING_AUTH_PATH.unlink(missing_ok=True)
|
||||
|
||||
@@ -81,9 +81,6 @@ 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:
|
||||
@@ -95,20 +92,7 @@ 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"
|
||||
}
|
||||
|
||||
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"
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -480,39 +480,6 @@ 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):
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""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,6 +1,4 @@
|
||||
"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog, beta headers."""
|
||||
|
||||
from unittest.mock import patch
|
||||
"""Tests for MiniMax provider hardening — context lengths, thinking guard, catalog."""
|
||||
|
||||
|
||||
class TestMinimaxContextLengths:
|
||||
@@ -105,100 +103,3 @@ 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
|
||||
|
||||
@@ -6,17 +6,6 @@ from unittest.mock import patch
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
|
||||
"""Verify the auto-launch command has all required flags."""
|
||||
assert cmd[0] == expected_chrome
|
||||
assert f"--remote-debugging-port={expected_port}" in cmd
|
||||
assert "--no-first-run" in cmd
|
||||
assert "--no-default-browser-check" in cmd
|
||||
user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")]
|
||||
assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag"
|
||||
assert "chrome-debug" in user_data_args[0]
|
||||
|
||||
|
||||
class TestChromeDebugLaunch:
|
||||
def test_windows_launch_uses_browser_found_on_path(self):
|
||||
captured = {}
|
||||
@@ -31,7 +20,7 @@ class TestChromeDebugLaunch:
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True
|
||||
|
||||
_assert_chrome_debug_cmd(captured["cmd"], r"C:\Chrome\chrome.exe", 9333)
|
||||
assert captured["cmd"] == [r"C:\Chrome\chrome.exe", "--remote-debugging-port=9333"]
|
||||
assert captured["kwargs"]["start_new_session"] is True
|
||||
|
||||
def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch):
|
||||
@@ -54,4 +43,4 @@ class TestChromeDebugLaunch:
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True
|
||||
|
||||
_assert_chrome_debug_cmd(captured["cmd"], installed, 9222)
|
||||
assert captured["cmd"] == [installed, "--remote-debugging-port=9222"]
|
||||
|
||||
@@ -147,20 +147,6 @@ 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
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
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,25 +49,6 @@ 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()}
|
||||
|
||||
@@ -41,7 +41,6 @@ def _attach_agent(
|
||||
session_completion_tokens=completion_tokens,
|
||||
session_total_tokens=total_tokens,
|
||||
session_api_calls=api_calls,
|
||||
get_rate_limit_state=lambda: None,
|
||||
context_compressor=SimpleNamespace(
|
||||
last_prompt_tokens=context_tokens,
|
||||
context_length=context_length,
|
||||
@@ -206,59 +205,6 @@ 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):
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
"""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"], "")
|
||||
@@ -1,138 +0,0 @@
|
||||
"""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
|
||||
@@ -38,8 +38,6 @@ def _isolate_hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
|
||||
monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
# Avoid making real calls during tests if this key is set in the env files
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -294,40 +294,6 @@ 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)
|
||||
|
||||
@@ -81,7 +81,6 @@ 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
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class FakeTree:
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self, *, intents, proxy=None):
|
||||
def __init__(self, *, intents):
|
||||
self.intents = intents
|
||||
self.user = SimpleNamespace(id=999, name="Hermes")
|
||||
self._events = {}
|
||||
@@ -95,7 +95,7 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all
|
||||
|
||||
created = {}
|
||||
|
||||
def fake_bot_factory(*, command_prefix, intents, proxy=None):
|
||||
def fake_bot_factory(*, command_prefix, intents):
|
||||
created["bot"] = FakeBot(intents=intents)
|
||||
return created["bot"]
|
||||
|
||||
@@ -124,7 +124,7 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
discord_platform.commands,
|
||||
"Bot",
|
||||
lambda **kwargs: FakeBot(intents=kwargs["intents"], proxy=kwargs.get("proxy")),
|
||||
lambda **kwargs: FakeBot(intents=kwargs["intents"]),
|
||||
)
|
||||
|
||||
async def fake_wait_for(awaitable, timeout):
|
||||
|
||||
@@ -91,7 +91,6 @@ 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
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ def adapter():
|
||||
fetch_channel=AsyncMock(),
|
||||
user=SimpleNamespace(id=99999, name="HermesBot"),
|
||||
)
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
return adapter
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ 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
|
||||
|
||||
@@ -38,11 +38,10 @@ def _make_timeout_error() -> httpx.TimeoutException:
|
||||
# cache_image_from_url (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||
class TestCacheImageFromUrl:
|
||||
"""Tests for gateway.platforms.base.cache_image_from_url"""
|
||||
|
||||
def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_success_on_first_attempt(self, tmp_path, monkeypatch):
|
||||
"""A clean 200 response caches the image and returns a path."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -66,7 +65,7 @@ class TestCacheImageFromUrl:
|
||||
assert path.endswith(".jpg")
|
||||
mock_client.get.assert_called_once()
|
||||
|
||||
def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
||||
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -96,7 +95,7 @@ class TestCacheImageFromUrl:
|
||||
assert mock_client.get.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch):
|
||||
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -123,7 +122,7 @@ class TestCacheImageFromUrl:
|
||||
assert path.endswith(".jpg")
|
||||
assert mock_client.get.call_count == 2
|
||||
|
||||
def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch):
|
||||
"""Timeout on every attempt raises after all retries are consumed."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -146,7 +145,7 @@ class TestCacheImageFromUrl:
|
||||
# 3 total calls: initial + 2 retries
|
||||
assert mock_client.get.call_count == 3
|
||||
|
||||
def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch):
|
||||
"""A 404 (non-retryable) is raised immediately without any retry."""
|
||||
monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", tmp_path / "img")
|
||||
|
||||
@@ -176,11 +175,10 @@ class TestCacheImageFromUrl:
|
||||
# cache_audio_from_url (base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||
class TestCacheAudioFromUrl:
|
||||
"""Tests for gateway.platforms.base.cache_audio_from_url"""
|
||||
|
||||
def test_success_on_first_attempt(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_success_on_first_attempt(self, tmp_path, monkeypatch):
|
||||
"""A clean 200 response caches the audio and returns a path."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -204,7 +202,7 @@ class TestCacheAudioFromUrl:
|
||||
assert path.endswith(".ogg")
|
||||
mock_client.get.assert_called_once()
|
||||
|
||||
def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):
|
||||
"""A timeout on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -234,7 +232,7 @@ class TestCacheAudioFromUrl:
|
||||
assert mock_client.get.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_retries_on_429_then_succeeds(self, tmp_path, monkeypatch):
|
||||
"""A 429 response on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -261,7 +259,7 @@ class TestCacheAudioFromUrl:
|
||||
assert path.endswith(".ogg")
|
||||
assert mock_client.get.call_count == 2
|
||||
|
||||
def test_retries_on_500_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_retries_on_500_then_succeeds(self, tmp_path, monkeypatch):
|
||||
"""A 500 response on the first attempt is retried; second attempt succeeds."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -288,7 +286,7 @@ class TestCacheAudioFromUrl:
|
||||
assert path.endswith(".ogg")
|
||||
assert mock_client.get.call_count == 2
|
||||
|
||||
def test_raises_after_max_retries_exhausted(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_raises_after_max_retries_exhausted(self, tmp_path, monkeypatch):
|
||||
"""Timeout on every attempt raises after all retries are consumed."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
@@ -311,7 +309,7 @@ class TestCacheAudioFromUrl:
|
||||
# 3 total calls: initial + 2 retries
|
||||
assert mock_client.get.call_count == 3
|
||||
|
||||
def test_non_retryable_4xx_raises_immediately(self, _mock_safe, tmp_path, monkeypatch):
|
||||
def test_non_retryable_4xx_raises_immediately(self, tmp_path, monkeypatch):
|
||||
"""A 404 (non-retryable) is raised immediately without any retry."""
|
||||
monkeypatch.setattr("gateway.platforms.base.AUDIO_CACHE_DIR", tmp_path / "audio")
|
||||
|
||||
|
||||
@@ -619,18 +619,6 @@ class TestFormatMessage:
|
||||
result = adapter.format_message("[click here](https://example.com)")
|
||||
assert result == "<https://example.com|click here>"
|
||||
|
||||
def test_link_conversion_strips_markdown_angle_brackets(self, adapter):
|
||||
result = adapter.format_message("[click here](<https://example.com>)")
|
||||
assert result == "<https://example.com|click here>"
|
||||
|
||||
def test_escapes_control_characters(self, adapter):
|
||||
result = adapter.format_message("AT&T < 5 > 3")
|
||||
assert result == "AT&T < 5 > 3"
|
||||
|
||||
def test_preserves_existing_slack_entities(self, adapter):
|
||||
text = "Hey <@U123>, see <https://example.com|example> and <!here>"
|
||||
assert adapter.format_message(text) == text
|
||||
|
||||
def test_strikethrough(self, adapter):
|
||||
assert adapter.format_message("~~deleted~~") == "~deleted~"
|
||||
|
||||
@@ -655,325 +643,6 @@ class TestFormatMessage:
|
||||
def test_none_passthrough(self, adapter):
|
||||
assert adapter.format_message(None) is None
|
||||
|
||||
def test_blockquote_preserved(self, adapter):
|
||||
"""Single-line blockquote > marker is preserved."""
|
||||
assert adapter.format_message("> quoted text") == "> quoted text"
|
||||
|
||||
def test_multiline_blockquote(self, adapter):
|
||||
"""Multi-line blockquote preserves > on each line."""
|
||||
text = "> line one\n> line two"
|
||||
assert adapter.format_message(text) == "> line one\n> line two"
|
||||
|
||||
def test_blockquote_with_formatting(self, adapter):
|
||||
"""Blockquote containing bold text."""
|
||||
assert adapter.format_message("> **bold quote**") == "> *bold quote*"
|
||||
|
||||
def test_nested_blockquote(self, adapter):
|
||||
"""Multiple > characters for nested quotes."""
|
||||
assert adapter.format_message(">> deeply quoted") == ">> deeply quoted"
|
||||
|
||||
def test_blockquote_mixed_with_plain(self, adapter):
|
||||
"""Blockquote lines interleaved with plain text."""
|
||||
text = "normal\n> quoted\nnormal again"
|
||||
result = adapter.format_message(text)
|
||||
assert "> quoted" in result
|
||||
assert "normal" in result
|
||||
|
||||
def test_non_prefix_gt_still_escaped(self, adapter):
|
||||
"""Greater-than in mid-line is still escaped."""
|
||||
assert adapter.format_message("5 > 3") == "5 > 3"
|
||||
|
||||
def test_blockquote_with_code(self, adapter):
|
||||
"""Blockquote containing inline code."""
|
||||
result = adapter.format_message("> use `fmt.Println`")
|
||||
assert result.startswith(">")
|
||||
assert "`fmt.Println`" in result
|
||||
|
||||
def test_bold_italic_combined(self, adapter):
|
||||
"""Triple-star ***text*** converts to Slack bold+italic *_text_*."""
|
||||
assert adapter.format_message("***hello***") == "*_hello_*"
|
||||
|
||||
def test_bold_italic_with_surrounding_text(self, adapter):
|
||||
"""Bold+italic in a sentence."""
|
||||
result = adapter.format_message("This is ***important*** stuff")
|
||||
assert "*_important_*" in result
|
||||
|
||||
def test_bold_italic_does_not_break_plain_bold(self, adapter):
|
||||
"""**bold** still works after adding ***bold italic*** support."""
|
||||
assert adapter.format_message("**bold**") == "*bold*"
|
||||
|
||||
def test_bold_italic_does_not_break_plain_italic(self, adapter):
|
||||
"""*italic* still works after adding ***bold italic*** support."""
|
||||
assert adapter.format_message("*italic*") == "_italic_"
|
||||
|
||||
def test_bold_italic_mixed_with_bold(self, adapter):
|
||||
"""Both ***bold italic*** and **bold** in the same message."""
|
||||
result = adapter.format_message("***important*** and **bold**")
|
||||
assert "*_important_*" in result
|
||||
assert "*bold*" in result
|
||||
|
||||
def test_pre_escaped_ampersand_not_double_escaped(self, adapter):
|
||||
"""Already-escaped & must not become &amp;."""
|
||||
assert adapter.format_message("&") == "&"
|
||||
|
||||
def test_pre_escaped_lt_not_double_escaped(self, adapter):
|
||||
"""Already-escaped < must not become &lt;."""
|
||||
assert adapter.format_message("<") == "<"
|
||||
|
||||
def test_pre_escaped_gt_not_double_escaped(self, adapter):
|
||||
"""Already-escaped > in plain text must not become &gt;."""
|
||||
assert adapter.format_message("5 > 3") == "5 > 3"
|
||||
|
||||
def test_mixed_raw_and_escaped_entities(self, adapter):
|
||||
"""Raw & and pre-escaped & coexist correctly."""
|
||||
result = adapter.format_message("AT&T and & entity")
|
||||
assert result == "AT&T and & entity"
|
||||
|
||||
def test_link_with_parentheses_in_url(self, adapter):
|
||||
"""Wikipedia-style URL with balanced parens is not truncated."""
|
||||
result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
|
||||
assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
|
||||
|
||||
def test_link_with_multiple_paren_pairs(self, adapter):
|
||||
"""URL with multiple balanced paren pairs."""
|
||||
result = adapter.format_message("[text](https://example.com/a_(b)_c_(d))")
|
||||
assert result == "<https://example.com/a_(b)_c_(d)|text>"
|
||||
|
||||
def test_link_without_parens_still_works(self, adapter):
|
||||
"""Normal URL without parens is unaffected by regex change."""
|
||||
result = adapter.format_message("[click](https://example.com/path?q=1)")
|
||||
assert result == "<https://example.com/path?q=1|click>"
|
||||
|
||||
def test_link_with_angle_brackets_and_parens(self, adapter):
|
||||
"""Angle-bracket URL with parens (CommonMark syntax)."""
|
||||
result = adapter.format_message("[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)")
|
||||
assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
|
||||
|
||||
def test_escaping_is_idempotent(self, adapter):
|
||||
"""Formatting already-formatted text produces the same result."""
|
||||
original = "AT&T < 5 > 3"
|
||||
once = adapter.format_message(original)
|
||||
twice = adapter.format_message(once)
|
||||
assert once == twice
|
||||
|
||||
# --- Entity preservation (spec-compliance) ---
|
||||
|
||||
def test_channel_mention_preserved(self, adapter):
|
||||
"""<!channel> special mention passes through unchanged."""
|
||||
assert adapter.format_message("Attention <!channel>") == "Attention <!channel>"
|
||||
|
||||
def test_everyone_mention_preserved(self, adapter):
|
||||
"""<!everyone> special mention passes through unchanged."""
|
||||
assert adapter.format_message("Hey <!everyone>") == "Hey <!everyone>"
|
||||
|
||||
def test_subteam_mention_preserved(self, adapter):
|
||||
"""<!subteam^ID> user group mention passes through unchanged."""
|
||||
assert adapter.format_message("Paging <!subteam^S12345>") == "Paging <!subteam^S12345>"
|
||||
|
||||
def test_date_formatting_preserved(self, adapter):
|
||||
"""<!date^...> formatting token passes through unchanged."""
|
||||
text = "Posted <!date^1392734382^{date_pretty}|Feb 18, 2014>"
|
||||
assert adapter.format_message(text) == text
|
||||
|
||||
def test_channel_link_preserved(self, adapter):
|
||||
"""<#CHANNEL_ID> channel link passes through unchanged."""
|
||||
assert adapter.format_message("Join <#C12345>") == "Join <#C12345>"
|
||||
|
||||
# --- Additional edge cases ---
|
||||
|
||||
def test_message_only_code_block(self, adapter):
|
||||
"""Entire message is a fenced code block — no conversion."""
|
||||
code = "```python\nx = 1\n```"
|
||||
assert adapter.format_message(code) == code
|
||||
|
||||
def test_multiline_mixed_formatting(self, adapter):
|
||||
"""Multi-line message with headers, bold, links, code, and blockquotes."""
|
||||
text = "## Title\n**bold** and [link](https://x.com)\n> quote\n`code`"
|
||||
result = adapter.format_message(text)
|
||||
assert result.startswith("*Title*")
|
||||
assert "*bold*" in result
|
||||
assert "<https://x.com|link>" in result
|
||||
assert "> quote" in result
|
||||
assert "`code`" in result
|
||||
|
||||
def test_markdown_unordered_list_with_asterisk(self, adapter):
|
||||
"""Asterisk list items must not trigger italic conversion."""
|
||||
text = "* item one\n* item two"
|
||||
result = adapter.format_message(text)
|
||||
assert "item one" in result
|
||||
assert "item two" in result
|
||||
|
||||
def test_nested_bold_in_link(self, adapter):
|
||||
"""Bold inside link label — label is stashed before bold pass."""
|
||||
result = adapter.format_message("[**bold**](https://example.com)")
|
||||
assert "https://example.com" in result
|
||||
assert "bold" in result
|
||||
|
||||
def test_url_with_query_string_and_ampersand(self, adapter):
|
||||
"""Ampersand in URL query string must not be escaped."""
|
||||
result = adapter.format_message("[link](https://x.com?a=1&b=2)")
|
||||
assert result == "<https://x.com?a=1&b=2|link>"
|
||||
|
||||
def test_emoji_shortcodes_passthrough(self, adapter):
|
||||
"""Emoji shortcodes like :smile: pass through unchanged."""
|
||||
assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestEditMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEditMessage:
|
||||
"""Verify that edit_message() applies mrkdwn formatting before sending."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_bold(self, adapter):
|
||||
"""edit_message converts **bold** to Slack *bold*."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "**hello world**")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "*hello world*"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_links(self, adapter):
|
||||
"""edit_message converts markdown links to Slack format."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "[click](https://example.com)")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "<https://example.com|click>"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_preserves_blockquotes(self, adapter):
|
||||
"""edit_message preserves blockquote > markers."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "> quoted text")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "> quoted text"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_escapes_control_chars(self, adapter):
|
||||
"""edit_message escapes & < > in plain text."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "1234.5678", "AT&T < 5 > 3")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == "AT&T < 5 > 3"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestEditMessageStreamingPipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEditMessageStreamingPipeline:
|
||||
"""E2E: verify that sequential streaming edits all go through format_message.
|
||||
|
||||
Simulates the GatewayStreamConsumer pattern where edit_message is called
|
||||
repeatedly with progressively longer accumulated text. Every call must
|
||||
produce properly formatted mrkdwn in the chat_update payload.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_streaming_updates(self, adapter):
|
||||
"""Simulates streaming: multiple edits, each should be formatted."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
# First streaming update — bold
|
||||
result1 = await adapter.edit_message("C123", "ts1", "**Processing**...")
|
||||
assert result1.success is True
|
||||
kwargs1 = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs1["text"] == "*Processing*..."
|
||||
|
||||
# Second streaming update — bold + link
|
||||
result2 = await adapter.edit_message(
|
||||
"C123", "ts1", "**Done!** See [results](https://example.com)"
|
||||
)
|
||||
assert result2.success is True
|
||||
kwargs2 = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs2["text"] == "*Done!* See <https://example.com|results>"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_code_and_bold(self, adapter):
|
||||
"""Streaming update with code block and bold — code must be preserved."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
content = "**Result:**\n```python\nprint('hello')\n```"
|
||||
result = await adapter.edit_message("C123", "ts1", content)
|
||||
assert result.success is True
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"].startswith("*Result:*")
|
||||
assert "```python\nprint('hello')\n```" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_blockquote_in_stream(self, adapter):
|
||||
"""Streaming update with blockquote — '>' marker must survive."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
content = "> **Important:** do this\nnormal line"
|
||||
result = await adapter.edit_message("C123", "ts1", content)
|
||||
assert result.success is True
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"].startswith("> *Important:*")
|
||||
assert "normal line" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_progressive_accumulation(self, adapter):
|
||||
"""Simulate real streaming: text grows with each edit, all formatted."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
|
||||
updates = [
|
||||
("**Step 1**", "*Step 1*"),
|
||||
("**Step 1**\n**Step 2**", "*Step 1*\n*Step 2*"),
|
||||
(
|
||||
"**Step 1**\n**Step 2**\nSee [docs](https://docs.example.com)",
|
||||
"*Step 1*\n*Step 2*\nSee <https://docs.example.com|docs>",
|
||||
),
|
||||
]
|
||||
|
||||
for raw, expected in updates:
|
||||
result = await adapter.edit_message("C123", "ts1", raw)
|
||||
assert result.success is True
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert kwargs["text"] == expected, f"Failed for input: {raw!r}"
|
||||
|
||||
# Total edit count should match number of updates
|
||||
assert adapter._app.client.chat_update.call_count == len(updates)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_bold_italic(self, adapter):
|
||||
"""Bold+italic ***text*** is formatted as *_text_* in edited messages."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "ts1", "***important*** update")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert "*_important_*" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_does_not_double_escape(self, adapter):
|
||||
"""Pre-escaped entities in edited messages must not get double-escaped."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "ts1", "5 > 3 and & entity")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert "&gt;" not in kwargs["text"]
|
||||
assert "&amp;" not in kwargs["text"]
|
||||
assert ">" in kwargs["text"]
|
||||
assert "&" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_formats_url_with_parens(self, adapter):
|
||||
"""Wikipedia-style URL with parens survives edit pipeline."""
|
||||
adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
|
||||
await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
|
||||
kwargs = adapter._app.client.chat_update.call_args.kwargs
|
||||
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_not_connected(self, adapter):
|
||||
"""edit_message returns failure when adapter is not connected."""
|
||||
adapter._app = None
|
||||
result = await adapter.edit_message("C123", "ts1", "**hello**")
|
||||
assert result.success is False
|
||||
assert "Not connected" in result.error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReactions
|
||||
@@ -1416,48 +1085,6 @@ class TestMessageSplitting:
|
||||
await adapter.send("C123", "hello world")
|
||||
assert adapter._app.client.chat_postMessage.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_preserves_blockquote_formatting(self, adapter):
|
||||
"""Blockquote '>' markers must survive format → chunk → send pipeline."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "> quoted text\nnormal text")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
sent_text = kwargs["text"]
|
||||
assert sent_text.startswith("> quoted text")
|
||||
assert "normal text" in sent_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_formats_bold_italic(self, adapter):
|
||||
"""Bold+italic ***text*** is formatted as *_text_* in sent messages."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "***important*** update")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "*_important_*" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_explicitly_enables_mrkdwn(self, adapter):
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "**hello**")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert kwargs.get("mrkdwn") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_does_not_double_escape_entities(self, adapter):
|
||||
"""Pre-escaped & in sent messages must not become &amp;."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "Use & for ampersand")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "&amp;" not in kwargs["text"]
|
||||
assert "&" in kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_formats_url_with_parens(self, adapter):
|
||||
"""Wikipedia-style URL with parens survives send pipeline."""
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
|
||||
await adapter.send("C123", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
|
||||
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReplyBroadcast
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"""
|
||||
Tests for Slack mention gating (require_mention / free_response_channels).
|
||||
|
||||
Follows the same pattern as test_whatsapp_group_gating.py.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock slack-bolt if not installed (same as test_slack.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_slack_mock():
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import gateway.platforms.slack as _slack_mod
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BOT_USER_ID = "U_BOT_123"
|
||||
CHANNEL_ID = "C0AQWDLHY9M"
|
||||
OTHER_CHANNEL_ID = "C9999999999"
|
||||
|
||||
|
||||
def _make_adapter(require_mention=None, free_response_channels=None):
|
||||
extra = {}
|
||||
if require_mention is not None:
|
||||
extra["require_mention"] = require_mention
|
||||
if free_response_channels is not None:
|
||||
extra["free_response_channels"] = free_response_channels
|
||||
|
||||
adapter = object.__new__(SlackAdapter)
|
||||
adapter.platform = Platform.SLACK
|
||||
adapter.config = PlatformConfig(enabled=True, extra=extra)
|
||||
adapter._bot_user_id = BOT_USER_ID
|
||||
adapter._team_bot_user_ids = {}
|
||||
return adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _slack_require_mention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_require_mention_defaults_to_true(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_false():
|
||||
adapter = _make_adapter(require_mention=False)
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_true():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_string_true():
|
||||
adapter = _make_adapter(require_mention="true")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_string_false():
|
||||
adapter = _make_adapter(require_mention="false")
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_string_no():
|
||||
adapter = _make_adapter(require_mention="no")
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_string_yes():
|
||||
adapter = _make_adapter(require_mention="yes")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_empty_string_stays_true():
|
||||
"""Empty/malformed strings keep gating ON (explicit-false parser)."""
|
||||
adapter = _make_adapter(require_mention="")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_malformed_string_stays_true():
|
||||
"""Unrecognised values keep gating ON (fail-closed)."""
|
||||
adapter = _make_adapter(require_mention="maybe")
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
def test_require_mention_env_var_fallback(monkeypatch):
|
||||
monkeypatch.setenv("SLACK_REQUIRE_MENTION", "false")
|
||||
adapter = _make_adapter() # no config value -> falls back to env
|
||||
assert adapter._slack_require_mention() is False
|
||||
|
||||
|
||||
def test_require_mention_env_var_default_true(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_require_mention() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _slack_free_response_channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_free_response_channels_default_empty(monkeypatch):
|
||||
monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
adapter = _make_adapter()
|
||||
assert adapter._slack_free_response_channels() == set()
|
||||
|
||||
|
||||
def test_free_response_channels_list():
|
||||
adapter = _make_adapter(free_response_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
def test_free_response_channels_csv_string():
|
||||
adapter = _make_adapter(free_response_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}")
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
def test_free_response_channels_empty_string():
|
||||
adapter = _make_adapter(free_response_channels="")
|
||||
assert adapter._slack_free_response_channels() == set()
|
||||
|
||||
|
||||
def test_free_response_channels_env_var_fallback(monkeypatch):
|
||||
monkeypatch.setenv("SLACK_FREE_RESPONSE_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}")
|
||||
adapter = _make_adapter() # no config value → falls back to env
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert CHANNEL_ID in result
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: mention gating integration (simulating _handle_slack_message logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,
|
||||
text="hello", mentioned=False, thread_reply=False,
|
||||
active_session=False):
|
||||
"""Simulate the mention gating logic from _handle_slack_message.
|
||||
|
||||
Returns True if the message would be processed, False if it would be
|
||||
skipped (returned early).
|
||||
"""
|
||||
bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
|
||||
if mentioned:
|
||||
text = f"<@{bot_uid}> {text}"
|
||||
is_mentioned = bot_uid and f"<@{bot_uid}>" in text
|
||||
|
||||
if not is_dm:
|
||||
if channel_id in adapter._slack_free_response_channels():
|
||||
return True
|
||||
elif not adapter._slack_require_mention():
|
||||
return True
|
||||
elif not is_mentioned:
|
||||
if thread_reply and active_session:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_default_require_mention_channel_without_mention_ignored():
|
||||
adapter = _make_adapter() # default: require_mention=True
|
||||
assert _would_process(adapter, text="hello everyone") is False
|
||||
|
||||
|
||||
def test_require_mention_false_channel_without_mention_processed():
|
||||
adapter = _make_adapter(require_mention=False)
|
||||
assert _would_process(adapter, text="hello everyone") is True
|
||||
|
||||
|
||||
def test_channel_in_free_response_processed_without_mention():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
free_response_channels=[CHANNEL_ID],
|
||||
)
|
||||
assert _would_process(adapter, channel_id=CHANNEL_ID, text="hello") is True
|
||||
|
||||
|
||||
def test_other_channel_not_in_free_response_still_gated():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True,
|
||||
free_response_channels=[CHANNEL_ID],
|
||||
)
|
||||
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
|
||||
|
||||
|
||||
def test_dm_always_processed_regardless_of_setting():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(adapter, is_dm=True, text="hello") is True
|
||||
|
||||
|
||||
def test_mentioned_message_always_processed():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(adapter, mentioned=True, text="what's up") is True
|
||||
|
||||
|
||||
def test_thread_reply_with_active_session_processed():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(
|
||||
adapter, text="followup",
|
||||
thread_reply=True, active_session=True,
|
||||
) is True
|
||||
|
||||
|
||||
def test_thread_reply_without_active_session_ignored():
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
assert _would_process(
|
||||
adapter, text="followup",
|
||||
thread_reply=True, active_session=False,
|
||||
) is False
|
||||
|
||||
|
||||
def test_bot_uid_none_processes_channel_message():
|
||||
"""When bot_uid is None (before auth_test), channel messages pass through.
|
||||
|
||||
This preserves the old behavior: the gating block is skipped entirely
|
||||
when bot_uid is falsy, so messages are not silently dropped during
|
||||
startup or for new workspaces.
|
||||
"""
|
||||
adapter = _make_adapter(require_mention=True)
|
||||
adapter._bot_user_id = None
|
||||
adapter._team_bot_user_ids = {}
|
||||
|
||||
# With bot_uid=None, the `if not is_dm and bot_uid:` condition is False,
|
||||
# so the gating block is skipped — message passes through.
|
||||
bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
|
||||
assert bot_uid is None
|
||||
|
||||
# Simulate: gating block not entered when bot_uid is falsy
|
||||
is_dm = False
|
||||
if not is_dm and bot_uid:
|
||||
result = False # would enter gating
|
||||
else:
|
||||
result = True # gating skipped, message processed
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: config bridging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"slack:\n"
|
||||
" require_mention: false\n"
|
||||
" free_response_channels:\n"
|
||||
" - C0AQWDLHY9M\n"
|
||||
" - C9999999999\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config is not None
|
||||
slack_extra = config.platforms[Platform.SLACK].extra
|
||||
assert slack_extra.get("require_mention") is False
|
||||
assert slack_extra.get("free_response_channels") == ["C0AQWDLHY9M", "C9999999999"]
|
||||
# Verify env vars were set by config bridging
|
||||
import os as _os
|
||||
assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
|
||||
assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"
|
||||
@@ -1,448 +0,0 @@
|
||||
"""Tests for text message batching across all gateway adapters.
|
||||
|
||||
When a user sends a long message, the messaging client splits it at the
|
||||
platform's character limit. Each adapter should buffer rapid successive
|
||||
text messages from the same session and aggregate them before dispatching.
|
||||
|
||||
Covers: Discord, Matrix, WeCom, and the adaptive delay logic for
|
||||
Telegram and Feishu.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SessionSource
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Helpers
|
||||
# =====================================================================
|
||||
|
||||
def _make_event(
|
||||
text: str,
|
||||
platform: Platform,
|
||||
chat_id: str = "12345",
|
||||
msg_type: MessageType = MessageType.TEXT,
|
||||
) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=msg_type,
|
||||
source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Discord text batching
|
||||
# =====================================================================
|
||||
|
||||
def _make_discord_adapter():
|
||||
"""Create a minimal DiscordAdapter for testing text batching."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._platform = Platform.DISCORD
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1 # fast for tests
|
||||
adapter._text_batch_split_delay_seconds = 0.3 # fast for tests
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestDiscordTextBatching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_message_dispatched_after_delay(self):
|
||||
adapter = _make_discord_adapter()
|
||||
event = _make_event("hello world", Platform.DISCORD)
|
||||
|
||||
adapter._enqueue_text_event(event)
|
||||
|
||||
# Not dispatched yet
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
# Wait for flush
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
dispatched = adapter.handle_message.call_args[0][0]
|
||||
assert dispatched.text == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_messages_aggregated(self):
|
||||
"""Two rapid messages from the same chat should be merged."""
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("Part one of a long", Platform.DISCORD))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("message that was split.", Platform.DISCORD))
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "Part one" in text
|
||||
assert "split" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_three_way_split_aggregated(self):
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("chunk 1", Platform.DISCORD))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("chunk 2", Platform.DISCORD))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("chunk 3", Platform.DISCORD))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "chunk 1" in text
|
||||
assert "chunk 2" in text
|
||||
assert "chunk 3" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_chats_not_merged(self):
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("from A", Platform.DISCORD, chat_id="111"))
|
||||
adapter._enqueue_text_event(_make_event("from B", Platform.DISCORD, chat_id="222"))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert adapter.handle_message.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_cleans_up_after_flush(self):
|
||||
adapter = _make_discord_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("test", Platform.DISCORD))
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert len(adapter._pending_text_batches) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_delay_for_near_limit_chunk(self):
|
||||
"""Chunks near the 2000-char limit should trigger longer delay."""
|
||||
adapter = _make_discord_adapter()
|
||||
# Simulate a chunk near Discord's 2000-char split point
|
||||
long_text = "x" * 1950
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.DISCORD))
|
||||
|
||||
# After the short delay (0.1s), should NOT have flushed yet (split delay is 0.3s)
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
# After the split delay, should be flushed
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Matrix text batching
|
||||
# =====================================================================
|
||||
|
||||
def _make_matrix_adapter():
|
||||
"""Create a minimal MatrixAdapter for testing text batching."""
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(MatrixAdapter)
|
||||
adapter._platform = Platform.MATRIX
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestMatrixTextBatching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_message_dispatched_after_delay(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
event = _make_event("hello world", Platform.MATRIX)
|
||||
|
||||
adapter._enqueue_text_event(event)
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
assert adapter.handle_message.call_args[0][0].text == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_messages_aggregated(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("first part", Platform.MATRIX))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("second part", Platform.MATRIX))
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "first part" in text
|
||||
assert "second part" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_rooms_not_merged(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("room A", Platform.MATRIX, chat_id="!aaa:matrix.org"))
|
||||
adapter._enqueue_text_event(_make_event("room B", Platform.MATRIX, chat_id="!bbb:matrix.org"))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert adapter.handle_message.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_delay_for_near_limit_chunk(self):
|
||||
"""Chunks near the 4000-char limit should trigger longer delay."""
|
||||
adapter = _make_matrix_adapter()
|
||||
long_text = "x" * 3950
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_cleans_up_after_flush(self):
|
||||
adapter = _make_matrix_adapter()
|
||||
adapter._enqueue_text_event(_make_event("test", Platform.MATRIX))
|
||||
await asyncio.sleep(0.2)
|
||||
assert len(adapter._pending_text_batches) == 0
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# WeCom text batching
|
||||
# =====================================================================
|
||||
|
||||
def _make_wecom_adapter():
|
||||
"""Create a minimal WeComAdapter for testing text batching."""
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(WeComAdapter)
|
||||
adapter._platform = Platform.WECOM
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestWeComTextBatching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_message_dispatched_after_delay(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
event = _make_event("hello world", Platform.WECOM)
|
||||
|
||||
adapter._enqueue_text_event(event)
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
assert adapter.handle_message.call_args[0][0].text == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_messages_aggregated(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("first part", Platform.WECOM))
|
||||
await asyncio.sleep(0.02)
|
||||
adapter._enqueue_text_event(_make_event("second part", Platform.WECOM))
|
||||
|
||||
adapter.handle_message.assert_not_called()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "first part" in text
|
||||
assert "second part" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_chats_not_merged(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("chat A", Platform.WECOM, chat_id="chat_a"))
|
||||
adapter._enqueue_text_event(_make_event("chat B", Platform.WECOM, chat_id="chat_b"))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert adapter.handle_message.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_delay_for_near_limit_chunk(self):
|
||||
"""Chunks near the 4000-char limit should trigger longer delay."""
|
||||
adapter = _make_wecom_adapter()
|
||||
long_text = "x" * 3950
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.WECOM))
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_cleans_up_after_flush(self):
|
||||
adapter = _make_wecom_adapter()
|
||||
adapter._enqueue_text_event(_make_event("test", Platform.WECOM))
|
||||
await asyncio.sleep(0.2)
|
||||
assert len(adapter._pending_text_batches) == 0
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Telegram adaptive delay (PR #6891)
|
||||
# =====================================================================
|
||||
|
||||
def _make_telegram_adapter():
|
||||
"""Create a minimal TelegramAdapter for testing adaptive delay."""
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter._platform = Platform.TELEGRAM
|
||||
adapter.config = config
|
||||
adapter._pending_text_batches = {}
|
||||
adapter._pending_text_batch_tasks = {}
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter.handle_message = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestTelegramAdaptiveDelay:
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_chunk_uses_normal_delay(self):
|
||||
adapter = _make_telegram_adapter()
|
||||
adapter._enqueue_text_event(_make_event("short msg", Platform.TELEGRAM))
|
||||
|
||||
# Should flush after the normal 0.1s delay
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_limit_chunk_uses_split_delay(self):
|
||||
"""A chunk near the 4096-char limit should trigger longer delay."""
|
||||
adapter = _make_telegram_adapter()
|
||||
long_text = "x" * 4050 # near the 4096 limit
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.TELEGRAM))
|
||||
|
||||
# After the short delay, should NOT have flushed yet
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
# After the split delay, should be flushed
|
||||
await asyncio.sleep(0.25)
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_continuation_merged(self):
|
||||
"""Two near-limit chunks should both be merged."""
|
||||
adapter = _make_telegram_adapter()
|
||||
|
||||
adapter._enqueue_text_event(_make_event("x" * 4050, Platform.TELEGRAM))
|
||||
await asyncio.sleep(0.05)
|
||||
adapter._enqueue_text_event(_make_event("continuation text", Platform.TELEGRAM))
|
||||
|
||||
# Short chunk arrived → should use normal delay now
|
||||
await asyncio.sleep(0.15)
|
||||
adapter.handle_message.assert_called_once()
|
||||
text = adapter.handle_message.call_args[0][0].text
|
||||
assert "continuation text" in text
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Feishu adaptive delay
|
||||
# =====================================================================
|
||||
|
||||
def _make_feishu_adapter():
|
||||
"""Create a minimal FeishuAdapter for testing adaptive delay."""
|
||||
from gateway.platforms.feishu import FeishuAdapter, FeishuBatchState
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._platform = Platform.FEISHU
|
||||
adapter.config = config
|
||||
batch_state = FeishuBatchState()
|
||||
adapter._pending_text_batches = batch_state.events
|
||||
adapter._pending_text_batch_tasks = batch_state.tasks
|
||||
adapter._pending_text_batch_counts = batch_state.counts
|
||||
adapter._text_batch_delay_seconds = 0.1
|
||||
adapter._text_batch_split_delay_seconds = 0.3
|
||||
adapter._text_batch_max_messages = 20
|
||||
adapter._text_batch_max_chars = 50000
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._message_handler = AsyncMock()
|
||||
adapter._handle_message_with_guards = AsyncMock()
|
||||
return adapter
|
||||
|
||||
|
||||
class TestFeishuAdaptiveDelay:
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_chunk_uses_normal_delay(self):
|
||||
adapter = _make_feishu_adapter()
|
||||
event = _make_event("short msg", Platform.FEISHU)
|
||||
await adapter._enqueue_text_event(event)
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter._handle_message_with_guards.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_limit_chunk_uses_split_delay(self):
|
||||
"""A chunk near the 4096-char limit should trigger longer delay."""
|
||||
adapter = _make_feishu_adapter()
|
||||
long_text = "x" * 4050
|
||||
event = _make_event(long_text, Platform.FEISHU)
|
||||
await adapter._enqueue_text_event(event)
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter._handle_message_with_guards.assert_not_called()
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
adapter._handle_message_with_guards.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_continuation_merged(self):
|
||||
adapter = _make_feishu_adapter()
|
||||
|
||||
await adapter._enqueue_text_event(_make_event("x" * 4050, Platform.FEISHU))
|
||||
await asyncio.sleep(0.05)
|
||||
await adapter._enqueue_text_event(_make_event("continuation text", Platform.FEISHU))
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
adapter._handle_message_with_guards.assert_called_once()
|
||||
text = adapter._handle_message_with_guards.call_args[0][0].text
|
||||
assert "continuation text" in text
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Tests for gateway /usage command — agent cache lookup and output fields."""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_mock_agent(**overrides):
|
||||
"""Create a mock AIAgent with realistic session counters."""
|
||||
agent = MagicMock()
|
||||
defaults = {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "openrouter",
|
||||
"base_url": None,
|
||||
"session_total_tokens": 50_000,
|
||||
"session_api_calls": 5,
|
||||
"session_prompt_tokens": 40_000,
|
||||
"session_completion_tokens": 10_000,
|
||||
"session_input_tokens": 35_000,
|
||||
"session_output_tokens": 10_000,
|
||||
"session_cache_read_tokens": 5_000,
|
||||
"session_cache_write_tokens": 2_000,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
for k, v in defaults.items():
|
||||
setattr(agent, k, v)
|
||||
|
||||
# Rate limit state
|
||||
rl = MagicMock()
|
||||
rl.has_data = True
|
||||
agent.get_rate_limit_state.return_value = rl
|
||||
|
||||
# Context compressor
|
||||
ctx = MagicMock()
|
||||
ctx.last_prompt_tokens = 30_000
|
||||
ctx.context_length = 200_000
|
||||
ctx.compression_count = 1
|
||||
agent.context_compressor = ctx
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
def _make_runner(session_key, agent=None, cached_agent=None):
|
||||
"""Build a bare GatewayRunner with just the fields _handle_usage_command needs."""
|
||||
from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner.session_store = MagicMock()
|
||||
|
||||
if agent is not None:
|
||||
runner._running_agents[session_key] = agent
|
||||
|
||||
if cached_agent is not None:
|
||||
runner._agent_cache[session_key] = (cached_agent, "sig")
|
||||
|
||||
# Wire helper
|
||||
runner._session_key_for_source = MagicMock(return_value=session_key)
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
SK = "agent:main:telegram:private:12345"
|
||||
|
||||
|
||||
class TestUsageCachedAgent:
|
||||
"""The main fix: /usage should find agents in _agent_cache between turns."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_agent_shows_detailed_usage(self):
|
||||
agent = _make_mock_agent()
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=0.1234, status="estimated")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "claude-sonnet-4.6" in result
|
||||
assert "35,000" in result # input tokens
|
||||
assert "10,000" in result # output tokens
|
||||
assert "5,000" in result # cache read
|
||||
assert "2,000" in result # cache write
|
||||
assert "50,000" in result # total
|
||||
assert "$0.1234" in result
|
||||
assert "30,000" in result # context
|
||||
assert "Compressions: 1" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_agent_preferred_over_cache(self):
|
||||
"""When agent is in both dicts, the running one wins."""
|
||||
running = _make_mock_agent(session_api_calls=10, session_total_tokens=80_000)
|
||||
cached = _make_mock_agent(session_api_calls=5, session_total_tokens=50_000)
|
||||
runner = _make_runner(SK, agent=running, cached_agent=cached)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="unknown")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "80,000" in result # running agent's total
|
||||
assert "API calls: 10" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentinel_skipped_uses_cache(self):
|
||||
"""PENDING sentinel in _running_agents should fall through to cache."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
|
||||
cached = _make_mock_agent()
|
||||
runner = _make_runner(SK, cached_agent=cached)
|
||||
runner._running_agents[SK] = _AGENT_PENDING_SENTINEL
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="unknown")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "claude-sonnet-4.6" in result
|
||||
assert "Session Token Usage" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_agent_anywhere_falls_to_history(self):
|
||||
"""No running or cached agent → rough estimate from transcript."""
|
||||
runner = _make_runner(SK)
|
||||
event = MagicMock()
|
||||
|
||||
session_entry = MagicMock()
|
||||
session_entry.session_id = "sess123"
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=500):
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Session Info" in result
|
||||
assert "Messages: 2" in result
|
||||
assert "~500" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_read_write_hidden_when_zero(self):
|
||||
"""Cache token lines should be omitted when zero."""
|
||||
agent = _make_mock_agent(session_cache_read_tokens=0, session_cache_write_tokens=0)
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="unknown")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Cache read" not in result
|
||||
assert "Cache write" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_included_status(self):
|
||||
"""Subscription-included providers show 'included' instead of dollar amount."""
|
||||
agent = _make_mock_agent(provider="openai-codex")
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.usage_pricing.estimate_usage_cost") as mock_cost:
|
||||
mock_cost.return_value = MagicMock(amount_usd=None, status="included")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Cost: included" in result
|
||||
@@ -4,7 +4,7 @@ import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -355,8 +355,7 @@ class TestMediaUpload:
|
||||
assert calls[3][1]["chunk_index"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("tools.url_safety.is_safe_url", return_value=True)
|
||||
async def test_download_remote_bytes_rejects_large_content_length(self, _mock_safe):
|
||||
async def test_download_remote_bytes_rejects_large_content_length(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
class FakeResponse:
|
||||
@@ -508,7 +507,6 @@ class TestInboundMessages:
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=(["/tmp/test.png"], ["image/png"]))
|
||||
|
||||
@@ -540,7 +538,6 @@ class TestInboundMessages:
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._extract_media = AsyncMock(return_value=([], []))
|
||||
|
||||
|
||||
@@ -628,21 +628,14 @@ class TestHasAnyProviderConfigured:
|
||||
def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path):
|
||||
"""Claude Code credentials should NOT skip the wizard when Hermes is unconfigured."""
|
||||
from hermes_cli import config as config_module
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
# Clear all provider env vars so earlier checks don't short-circuit
|
||||
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
if pconfig.auth_type == "api_key":
|
||||
_all_vars.update(pconfig.api_key_env_vars)
|
||||
for var in _all_vars:
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Prevent gh-cli / copilot auth fallback from leaking in
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {})
|
||||
# Simulate valid Claude Code credentials
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
@@ -717,7 +710,6 @@ class TestHasAnyProviderConfigured:
|
||||
"""config.yaml model dict with empty default and no creds stays false."""
|
||||
import yaml
|
||||
from hermes_cli import config as config_module
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_file = hermes_home / "config.yaml"
|
||||
@@ -727,15 +719,9 @@ class TestHasAnyProviderConfigured:
|
||||
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
|
||||
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
if pconfig.auth_type == "api_key":
|
||||
_all_vars.update(pconfig.api_key_env_vars)
|
||||
for var in _all_vars:
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
# Prevent gh-cli / copilot auth fallback from leaking in
|
||||
monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {})
|
||||
from hermes_cli.main import _has_any_provider_configured
|
||||
assert _has_any_provider_configured() is False
|
||||
|
||||
@@ -955,10 +941,9 @@ class TestHuggingFaceModels:
|
||||
"""Every HF model should have a context length entry."""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
from agent.model_metadata import DEFAULT_CONTEXT_LENGTHS
|
||||
lower_keys = {k.lower() for k in DEFAULT_CONTEXT_LENGTHS}
|
||||
hf_models = _PROVIDER_MODELS["huggingface"]
|
||||
for model in hf_models:
|
||||
assert model.lower() in lower_keys, (
|
||||
assert model in DEFAULT_CONTEXT_LENGTHS, (
|
||||
f"HF model {model!r} missing from DEFAULT_CONTEXT_LENGTHS"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,30 +49,6 @@ def test_chat_subcommand_accepts_skills_flag(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_chat_subcommand_accepts_image_flag(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["query"] = args.query
|
||||
captured["image"] = args.image
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "chat", "-q", "hello", "--image", "~/storage/shared/Pictures/cat.png"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"query": "hello",
|
||||
"image": "~/storage/shared/Pictures/cat.png",
|
||||
}
|
||||
|
||||
|
||||
def test_continue_worktree_and_skills_flags_work_together(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
|
||||
@@ -68,17 +68,6 @@ class TestCommandRegistry:
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
assert cmd.category in valid_categories, f"{cmd.name} has invalid category '{cmd.category}'"
|
||||
|
||||
def test_reasoning_subcommands_are_in_logical_order(self):
|
||||
reasoning = next(cmd for cmd in COMMAND_REGISTRY if cmd.name == "reasoning")
|
||||
assert reasoning.subcommands[:6] == (
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
)
|
||||
|
||||
def test_cli_only_and_gateway_only_are_mutually_exclusive(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
assert not (cmd.cli_only and cmd.gateway_only), \
|
||||
@@ -446,13 +435,6 @@ class TestSubcommands:
|
||||
assert "show" in subs
|
||||
assert "hide" in subs
|
||||
|
||||
def test_fast_has_subcommands(self):
|
||||
assert "/fast" in SUBCOMMANDS
|
||||
subs = SUBCOMMANDS["/fast"]
|
||||
assert "fast" in subs
|
||||
assert "normal" in subs
|
||||
assert "status" in subs
|
||||
|
||||
def test_voice_has_subcommands(self):
|
||||
assert "/voice" in SUBCOMMANDS
|
||||
assert "on" in SUBCOMMANDS["/voice"]
|
||||
@@ -481,20 +463,6 @@ class TestSubcommandCompletion:
|
||||
assert "high" in texts
|
||||
assert "show" in texts
|
||||
|
||||
def test_fast_subcommand_completion_after_space(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/fast ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "fast" in texts
|
||||
assert "normal" in texts
|
||||
|
||||
def test_fast_command_filtered_out_when_unavailable(self):
|
||||
completions = _completions(
|
||||
SlashCommandCompleter(command_filter=lambda cmd: cmd != "/fast"),
|
||||
"/fa",
|
||||
)
|
||||
texts = {c.text for c in completions}
|
||||
assert "fast" not in texts
|
||||
|
||||
def test_subcommand_prefix_filters(self):
|
||||
"""Typing '/reasoning sh' should only show 'show'."""
|
||||
completions = _completions(SlashCommandCompleter(), "/reasoning sh")
|
||||
@@ -548,13 +516,6 @@ class TestGhostText:
|
||||
"""/reasoning sh → 'ow'"""
|
||||
assert _suggestion("/reasoning sh") == "ow"
|
||||
|
||||
def test_fast_subcommand_suggestion(self):
|
||||
assert _suggestion("/fast f") == "ast"
|
||||
|
||||
def test_fast_subcommand_suggestion_hidden_when_filtered(self):
|
||||
completer = SlashCommandCompleter(command_filter=lambda cmd: cmd != "/fast")
|
||||
assert _suggestion("/fa", completer=completer) is None
|
||||
|
||||
def test_no_suggestion_for_non_slash(self):
|
||||
assert _suggestion("hello") is None
|
||||
|
||||
|
||||
@@ -14,23 +14,6 @@ from hermes_cli import doctor as doctor_mod
|
||||
from hermes_cli.doctor import _has_provider_env_config
|
||||
|
||||
|
||||
class TestDoctorPlatformHints:
|
||||
def test_termux_package_hint(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
assert doctor._is_termux() is True
|
||||
assert doctor._python_install_cmd() == "python -m pip install"
|
||||
assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep"
|
||||
|
||||
def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch):
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
assert doctor._is_termux() is False
|
||||
assert doctor._python_install_cmd() == "uv pip install"
|
||||
assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep"
|
||||
|
||||
|
||||
class TestProviderEnvDetection:
|
||||
def test_detects_openai_api_key(self):
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
|
||||
@@ -223,72 +206,3 @@ class TestDoctorMemoryProviderSection:
|
||||
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
|
||||
assert "Memory Provider" in out
|
||||
assert "Built-in memory active" not in out
|
||||
|
||||
|
||||
def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
|
||||
helper = TestDoctorMemoryProviderSection()
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
|
||||
real_which = doctor_mod.shutil.which
|
||||
|
||||
def fake_which(cmd):
|
||||
if cmd in {"docker", "node", "npm"}:
|
||||
return None
|
||||
return real_which(cmd)
|
||||
|
||||
monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
|
||||
|
||||
out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
|
||||
|
||||
assert "Docker backend is not available inside Termux" in out
|
||||
assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
|
||||
assert "Install Node.js on Termux with: pkg install nodejs" in out
|
||||
assert "Termux browser setup:" in out
|
||||
assert "1) pkg install nodejs" in out
|
||||
assert "2) npm install -g agent-browser" in out
|
||||
assert "3) agent-browser install" in out
|
||||
assert "docker not found (optional)" not in out
|
||||
|
||||
|
||||
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
|
||||
project = tmp_path / "project"
|
||||
project.mkdir(exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
|
||||
TOOLSET_REQUIREMENTS={
|
||||
"terminal": {"name": "terminal"},
|
||||
"browser": {"name": "browser"},
|
||||
},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import io, contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "✓ browser" not in out
|
||||
assert "browser" in out
|
||||
assert "system dependency not met" in out
|
||||
assert "agent-browser is not installed (expected in the tested Termux path)" in out
|
||||
assert "npm install -g agent-browser && agent-browser install" in out
|
||||
|
||||
@@ -10,7 +10,6 @@ import hermes_cli.gateway as gateway
|
||||
class TestSystemdLingerStatus:
|
||||
def test_reports_enabled(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setenv("USER", "alice")
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess,
|
||||
@@ -23,7 +22,6 @@ class TestSystemdLingerStatus:
|
||||
|
||||
def test_reports_disabled(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setenv("USER", "alice")
|
||||
monkeypatch.setattr(
|
||||
gateway.subprocess,
|
||||
@@ -34,11 +32,6 @@ class TestSystemdLingerStatus:
|
||||
|
||||
assert gateway.get_systemd_linger_status() == (False, "")
|
||||
|
||||
def test_reports_termux_as_not_supported(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: True)
|
||||
|
||||
assert gateway.get_systemd_linger_status() == (None, "not supported in Termux")
|
||||
|
||||
|
||||
def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys):
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
|
||||
@@ -8,7 +8,6 @@ import hermes_cli.gateway as gateway
|
||||
class TestEnsureLingerEnabled:
|
||||
def test_linger_already_enabled_via_file(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: True))
|
||||
|
||||
@@ -23,7 +22,6 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_status_enabled_skips_enable(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (True, ""))
|
||||
@@ -39,7 +37,6 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_loginctl_success_enables_linger(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, ""))
|
||||
@@ -62,7 +59,6 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_missing_loginctl_shows_manual_guidance(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (None, "loginctl not found"))
|
||||
@@ -80,7 +76,6 @@ class TestEnsureLingerEnabled:
|
||||
|
||||
def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
|
||||
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
|
||||
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, ""))
|
||||
|
||||
@@ -109,8 +109,7 @@ class TestGatewayStopCleanup:
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
|
||||
@@ -135,8 +134,7 @@ class TestGatewayStopCleanup:
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
|
||||
@@ -258,8 +256,7 @@ class TestGatewayServiceDetection:
|
||||
user_unit = SimpleNamespace(exists=lambda: True)
|
||||
system_unit = SimpleNamespace(exists=lambda: True)
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
@@ -281,8 +278,7 @@ class TestGatewayServiceDetection:
|
||||
|
||||
class TestGatewaySystemServiceRouting:
|
||||
def test_gateway_install_passes_system_flags(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
calls = []
|
||||
@@ -298,30 +294,11 @@ class TestGatewaySystemServiceRouting:
|
||||
|
||||
assert calls == [(True, True, "alice")]
|
||||
|
||||
def test_gateway_install_reports_termux_manual_mode(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
try:
|
||||
gateway_cli.gateway_command(
|
||||
SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None)
|
||||
)
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 1
|
||||
else:
|
||||
raise AssertionError("Expected gateway_command to exit on unsupported Termux service install")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "not supported on Termux" in out
|
||||
assert "Run manually: hermes gateway" in out
|
||||
|
||||
def test_gateway_status_prefers_system_service_when_only_system_unit_exists(self, monkeypatch):
|
||||
user_unit = SimpleNamespace(exists=lambda: False)
|
||||
system_unit = SimpleNamespace(exists=lambda: True)
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
@@ -336,20 +313,6 @@ class TestGatewaySystemServiceRouting:
|
||||
|
||||
assert calls == [(False, False)]
|
||||
|
||||
def test_gateway_status_on_termux_shows_manual_guidance(self, monkeypatch, capsys):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "find_gateway_pids", lambda exclude_pids=None: [])
|
||||
monkeypatch.setattr(gateway_cli, "_runtime_health_lines", lambda: [])
|
||||
|
||||
gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Gateway is not running" in out
|
||||
assert "nohup hermes gateway" in out
|
||||
assert "install as user service" not in out
|
||||
|
||||
def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch):
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text("plist\n", encoding="utf-8")
|
||||
@@ -550,22 +513,12 @@ class TestGeneratedUnitUsesDetectedVenv:
|
||||
class TestGeneratedUnitIncludesLocalBin:
|
||||
"""~/.local/bin must be in PATH so uvx/pipx tools are discoverable."""
|
||||
|
||||
def test_user_unit_includes_local_bin_in_path(self, monkeypatch):
|
||||
home = Path.home()
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_build_user_local_paths",
|
||||
lambda home_path, existing: [str(home / ".local" / "bin")],
|
||||
)
|
||||
def test_user_unit_includes_local_bin_in_path(self):
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
home = str(Path.home())
|
||||
assert f"{home}/.local/bin" in unit
|
||||
|
||||
def test_system_unit_includes_local_bin_in_path(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_build_user_local_paths",
|
||||
lambda home_path, existing: [str(home_path / ".local" / "bin")],
|
||||
)
|
||||
def test_system_unit_includes_local_bin_in_path(self):
|
||||
unit = gateway_cli.generate_systemd_unit(system=True)
|
||||
# System unit uses the resolved home dir from _system_service_identity
|
||||
assert "/.local/bin" in unit
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
from hermes_cli.main import _prompt_reasoning_effort_selection
|
||||
|
||||
|
||||
class _FakeTerminalMenu:
|
||||
last_choices = None
|
||||
|
||||
def __init__(self, choices, **kwargs):
|
||||
_FakeTerminalMenu.last_choices = choices
|
||||
self._cursor_index = kwargs.get("cursor_index")
|
||||
|
||||
def show(self):
|
||||
return self._cursor_index
|
||||
|
||||
|
||||
def test_reasoning_menu_orders_minimal_before_low(monkeypatch):
|
||||
fake_module = types.SimpleNamespace(TerminalMenu=_FakeTerminalMenu)
|
||||
monkeypatch.setitem(sys.modules, "simple_term_menu", fake_module)
|
||||
|
||||
selected = _prompt_reasoning_effort_selection(
|
||||
["low", "minimal", "medium", "high"],
|
||||
current_effort="medium",
|
||||
)
|
||||
|
||||
assert selected == "medium"
|
||||
assert _FakeTerminalMenu.last_choices[:4] == [
|
||||
" minimal",
|
||||
" low",
|
||||
" medium ← currently in use",
|
||||
" high",
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SETUP_SCRIPT = REPO_ROOT / "setup-hermes.sh"
|
||||
|
||||
|
||||
def test_setup_hermes_script_is_valid_shell():
|
||||
result = subprocess.run(["bash", "-n", str(SETUP_SCRIPT)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_setup_hermes_script_has_termux_path():
|
||||
content = SETUP_SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert "is_termux()" in content
|
||||
assert ".[termux]" in content
|
||||
assert "constraints-termux.txt" in content
|
||||
assert "$PREFIX/bin" in content
|
||||
assert "Skipping tinker-atropos on Termux" in content
|
||||
@@ -12,33 +12,3 @@ def test_show_status_includes_tavily_key(monkeypatch, capsys, tmp_path):
|
||||
output = capsys.readouterr().out
|
||||
assert "Tavily" in output
|
||||
assert "tvly...cdef" in output
|
||||
|
||||
|
||||
def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys, tmp_path):
|
||||
from hermes_cli import status as status_mod
|
||||
import hermes_cli.auth as auth_mod
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
|
||||
monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
|
||||
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False)
|
||||
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False)
|
||||
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False)
|
||||
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
|
||||
|
||||
def _unexpected_systemctl(*args, **kwargs):
|
||||
raise AssertionError("systemctl should not be called in the Termux status view")
|
||||
|
||||
monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_systemctl)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Manager: Termux / manual process" in output
|
||||
assert "Start with: hermes gateway" in output
|
||||
assert "systemd (user)" not in output
|
||||
|
||||
@@ -354,14 +354,6 @@ def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
||||
lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
|
||||
# Prevent leaked platform tokens (e.g. DISCORD_BOT_TOKEN from gateway.run
|
||||
# import) from adding extra platforms. The loop in tools_command runs
|
||||
# apply_nous_managed_defaults per platform; a second iteration sees values
|
||||
# set by the first as "explicit" and skips them.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config._get_enabled_platforms",
|
||||
lambda: ["cli"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_auth_status",
|
||||
lambda: {"logged_in": True},
|
||||
|
||||
@@ -213,12 +213,8 @@ def test_restore_stashed_changes_keeps_going_when_drop_fails(monkeypatch, tmp_pa
|
||||
assert "git stash drop stash@{0}" in out
|
||||
|
||||
|
||||
def test_restore_stashed_changes_always_resets_on_conflict(monkeypatch, tmp_path, capsys):
|
||||
"""Conflicts always auto-reset (no prompt) and return False, even interactively.
|
||||
|
||||
Leaving conflict markers in source files makes hermes unrunnable (SyntaxError).
|
||||
The stash is preserved for manual recovery; cmd_update continues normally.
|
||||
"""
|
||||
def test_restore_stashed_changes_prompts_before_reset_on_conflict(monkeypatch, tmp_path, capsys):
|
||||
"""When conflicts occur interactively, user is prompted before reset."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
@@ -234,19 +230,45 @@ def test_restore_stashed_changes_always_resets_on_conflict(monkeypatch, tmp_path
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr("builtins.input", lambda: "y")
|
||||
|
||||
result = hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True)
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True)
|
||||
|
||||
assert result is False
|
||||
out = capsys.readouterr().out
|
||||
assert "Conflicted files:" in out
|
||||
assert "hermes_cli/main.py" in out
|
||||
assert "stashed changes are preserved" in out
|
||||
assert "Reset working tree to clean state" in out
|
||||
assert "Working tree reset to clean state" in out
|
||||
assert "git stash apply abc123" in out
|
||||
reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
|
||||
assert len(reset_calls) == 1
|
||||
|
||||
|
||||
def test_restore_stashed_changes_user_declines_reset(monkeypatch, tmp_path, capsys):
|
||||
"""When user declines reset, working tree is left as-is."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append((cmd, kwargs))
|
||||
if cmd[1:3] == ["stash", "apply"]:
|
||||
return SimpleNamespace(stdout="", stderr="conflict\n", returncode=1)
|
||||
if cmd[1:3] == ["diff", "--name-only"]:
|
||||
return SimpleNamespace(stdout="cli.py\n", stderr="", returncode=0)
|
||||
raise AssertionError(f"unexpected command: {cmd}")
|
||||
|
||||
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
|
||||
# First input: "y" to restore, second input: "n" to decline reset
|
||||
inputs = iter(["y", "n"])
|
||||
monkeypatch.setattr("builtins.input", lambda: next(inputs))
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
hermes_main._restore_stashed_changes(["git"], tmp_path, "abc123", prompt_user=True)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "left as-is" in out
|
||||
reset_calls = [c for c, _ in calls if c[1:3] == ["reset", "--hard"]]
|
||||
assert len(reset_calls) == 0
|
||||
|
||||
|
||||
def test_restore_stashed_changes_auto_resets_non_interactive(monkeypatch, tmp_path, capsys):
|
||||
"""Non-interactive mode auto-resets without prompting and returns False
|
||||
instead of sys.exit(1) so the update can continue (gateway /update path)."""
|
||||
|
||||
@@ -368,8 +368,6 @@ class TestCmdUpdateLaunchdRestart:
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "is_macos", lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -428,8 +426,7 @@ class TestCmdUpdateSystemService:
|
||||
):
|
||||
"""When user systemd is inactive but a system service exists, restart via system scope."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -458,8 +455,7 @@ class TestCmdUpdateSystemService:
|
||||
):
|
||||
"""When system service restart fails, show the failure message."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -481,8 +477,7 @@ class TestCmdUpdateSystemService:
|
||||
):
|
||||
"""When both user and system services are active, both are restarted."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
commit_count="3",
|
||||
@@ -565,8 +560,7 @@ class TestServicePidExclusion:
|
||||
):
|
||||
"""After systemd restart, the sweep must exclude the service PID."""
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
|
||||
SERVICE_PID = 55000
|
||||
|
||||
@@ -645,8 +639,7 @@ class TestGetServicePids:
|
||||
"""Unit tests for _get_service_pids()."""
|
||||
|
||||
def test_returns_systemd_main_pid(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
@@ -695,8 +688,7 @@ class TestGetServicePids:
|
||||
|
||||
def test_excludes_zero_pid(self, monkeypatch):
|
||||
"""systemd returns MainPID=0 for stopped services; skip those."""
|
||||
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
|
||||
@@ -172,87 +172,6 @@ class TestHTTP413Compression:
|
||||
mock_compress.assert_called_once()
|
||||
assert result["completed"] is True
|
||||
|
||||
def test_413_clears_conversation_history_on_persist(self, agent):
|
||||
"""After 413-triggered compression, _persist_session must receive None history.
|
||||
|
||||
Bug: _compress_context() creates a new session and resets _last_flushed_db_idx=0,
|
||||
but if conversation_history still holds the original (pre-compression) list,
|
||||
_flush_messages_to_session_db computes flush_from = max(len(history), 0) which
|
||||
exceeds len(compressed_messages), so messages[flush_from:] is empty and nothing
|
||||
is written to the new session → "Session found but has no messages" on resume.
|
||||
"""
|
||||
err_413 = _make_413_error()
|
||||
ok_resp = _mock_response(content="OK", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [err_413, ok_resp]
|
||||
|
||||
big_history = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
|
||||
for i in range(200)
|
||||
]
|
||||
|
||||
persist_calls = []
|
||||
|
||||
with (
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(
|
||||
agent, "_persist_session",
|
||||
side_effect=lambda msgs, hist: persist_calls.append(hist),
|
||||
),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
mock_compress.return_value = (
|
||||
[{"role": "user", "content": "summary"}],
|
||||
"compressed prompt",
|
||||
)
|
||||
agent.run_conversation("hello", conversation_history=big_history)
|
||||
|
||||
assert len(persist_calls) >= 1, "Expected at least one _persist_session call"
|
||||
for hist in persist_calls:
|
||||
assert hist is None, (
|
||||
f"conversation_history should be None after mid-loop compression, "
|
||||
f"got list with {len(hist)} items"
|
||||
)
|
||||
|
||||
def test_context_overflow_clears_conversation_history_on_persist(self, agent):
|
||||
"""After context-overflow compression, _persist_session must receive None history."""
|
||||
err_400 = Exception(
|
||||
"Error code: 400 - This endpoint's maximum context length is 128000 tokens. "
|
||||
"However, you requested about 270460 tokens."
|
||||
)
|
||||
err_400.status_code = 400
|
||||
ok_resp = _mock_response(content="OK", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [err_400, ok_resp]
|
||||
|
||||
big_history = [
|
||||
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
|
||||
for i in range(200)
|
||||
]
|
||||
|
||||
persist_calls = []
|
||||
|
||||
with (
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(
|
||||
agent, "_persist_session",
|
||||
side_effect=lambda msgs, hist: persist_calls.append(hist),
|
||||
),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
mock_compress.return_value = (
|
||||
[{"role": "user", "content": "summary"}],
|
||||
"compressed prompt",
|
||||
)
|
||||
agent.run_conversation("hello", conversation_history=big_history)
|
||||
|
||||
assert len(persist_calls) >= 1
|
||||
for hist in persist_calls:
|
||||
assert hist is None, (
|
||||
f"conversation_history should be None after context-overflow compression, "
|
||||
f"got list with {len(hist)} items"
|
||||
)
|
||||
|
||||
def test_400_context_length_triggers_compression(self, agent):
|
||||
"""A 400 with 'maximum context length' should trigger compression, not abort as generic 4xx.
|
||||
|
||||
|
||||
@@ -225,26 +225,6 @@ class TestDeveloperRoleSwap:
|
||||
assert kwargs["messages"][0]["role"] == "developer"
|
||||
|
||||
|
||||
class TestBuildApiKwargsChatCompletionsServiceTier:
|
||||
"""service_tier via request_overrides works on the chat_completions path."""
|
||||
|
||||
def test_includes_service_tier_via_request_overrides(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openrouter")
|
||||
agent.model = "gpt-4.1"
|
||||
agent.request_overrides = {"service_tier": "priority"}
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["service_tier"] == "priority"
|
||||
|
||||
def test_no_service_tier_when_overrides_empty(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openrouter")
|
||||
agent.model = "gpt-4.1"
|
||||
agent.request_overrides = {}
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "service_tier" not in kwargs
|
||||
|
||||
|
||||
class TestBuildApiKwargsAIGateway:
|
||||
def test_uses_chat_completions_format(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
|
||||
@@ -376,25 +356,6 @@ class TestBuildApiKwargsCodex:
|
||||
assert "reasoning" in kwargs
|
||||
assert kwargs["reasoning"]["effort"] == "medium"
|
||||
|
||||
def test_includes_service_tier_via_request_overrides(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses",
|
||||
base_url="https://chatgpt.com/backend-api/codex")
|
||||
agent.model = "gpt-5.4"
|
||||
agent.service_tier = "priority"
|
||||
agent.request_overrides = {"service_tier": "priority"}
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert kwargs["service_tier"] == "priority"
|
||||
|
||||
def test_omits_max_output_tokens_for_codex_backend(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses",
|
||||
base_url="https://chatgpt.com/backend-api/codex")
|
||||
agent.model = "gpt-5.4"
|
||||
agent.max_tokens = 20
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
kwargs = agent._build_api_kwargs(messages)
|
||||
assert "max_output_tokens" not in kwargs
|
||||
|
||||
def test_includes_encrypted_content_in_include(self, monkeypatch):
|
||||
agent = _make_agent(monkeypatch, "openai-codex", api_mode="codex_responses",
|
||||
base_url="https://chatgpt.com/backend-api/codex")
|
||||
|
||||
@@ -5,7 +5,6 @@ pieces. The OpenAI client and tool loading are mocked so no network calls
|
||||
are made.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -1062,77 +1061,6 @@ class TestExecuteToolCalls:
|
||||
assert len(messages[0]["content"]) < 150_000
|
||||
assert ("Truncated" in messages[0]["content"] or "<persisted-output>" in messages[0]["content"])
|
||||
|
||||
def test_quiet_tool_output_suppressed_when_progress_callback_present(self, agent):
|
||||
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
agent.tool_progress_callback = lambda *args, **kwargs: None
|
||||
|
||||
with patch("run_agent.handle_function_call", return_value="search result"), \
|
||||
patch.object(agent, "_safe_print") as mock_print:
|
||||
agent._execute_tool_calls(mock_msg, messages, "task-1")
|
||||
|
||||
mock_print.assert_not_called()
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "tool"
|
||||
|
||||
def test_quiet_tool_output_prints_without_progress_callback(self, agent):
|
||||
tc = _mock_tool_call(name="web_search", arguments='{"q":"test"}', call_id="c1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
agent.tool_progress_callback = None
|
||||
|
||||
with patch("run_agent.handle_function_call", return_value="search result"), \
|
||||
patch.object(agent, "_safe_print") as mock_print:
|
||||
agent._execute_tool_calls(mock_msg, messages, "task-1")
|
||||
|
||||
mock_print.assert_called_once()
|
||||
assert "search" in str(mock_print.call_args.args[0]).lower()
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["role"] == "tool"
|
||||
|
||||
def test_vprint_suppressed_in_parseable_quiet_mode(self, agent):
|
||||
agent.suppress_status_output = True
|
||||
|
||||
with patch.object(agent, "_safe_print") as mock_print:
|
||||
agent._vprint("status line", force=True)
|
||||
agent._vprint("normal line")
|
||||
|
||||
mock_print.assert_not_called()
|
||||
|
||||
def test_run_conversation_suppresses_retry_noise_in_parseable_quiet_mode(self, agent):
|
||||
class _RateLimitError(Exception):
|
||||
status_code = 429
|
||||
|
||||
def __str__(self):
|
||||
return "Error code: 429 - Rate limit exceeded."
|
||||
|
||||
responses = [_RateLimitError(), _mock_response(content="Recovered")]
|
||||
|
||||
def _fake_api_call(api_kwargs):
|
||||
result = responses.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
agent.suppress_status_output = True
|
||||
agent._interruptible_api_call = _fake_api_call
|
||||
agent._persist_session = lambda *args, **kwargs: None
|
||||
agent._save_trajectory = lambda *args, **kwargs: None
|
||||
agent._save_session_log = lambda *args, **kwargs: None
|
||||
|
||||
captured = io.StringIO()
|
||||
agent._print_fn = lambda *args, **kw: print(*args, file=captured, **kw)
|
||||
|
||||
with patch("run_agent.time.sleep", return_value=None):
|
||||
result = agent.run_conversation("hello")
|
||||
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "Recovered"
|
||||
output = captured.getvalue()
|
||||
assert "API call failed" not in output
|
||||
assert "Rate limit reached" not in output
|
||||
|
||||
|
||||
class TestConcurrentToolExecution:
|
||||
"""Tests for _execute_tool_calls_concurrent and dispatch logic."""
|
||||
@@ -1949,68 +1877,6 @@ class TestRunConversation:
|
||||
assert result["final_response"] is not None
|
||||
assert "Thinking Budget Exhausted" in result["final_response"]
|
||||
|
||||
def test_length_with_tool_calls_returns_partial_without_executing_tools(self, agent):
|
||||
self._setup_agent(agent)
|
||||
bad_tc = _mock_tool_call(
|
||||
name="write_file",
|
||||
arguments='{"path":"report.md","content":"partial',
|
||||
call_id="c1",
|
||||
)
|
||||
resp = _mock_response(content="", finish_reason="length", tool_calls=[bad_tc])
|
||||
agent.client.chat.completions.create.return_value = resp
|
||||
|
||||
with (
|
||||
patch("run_agent.handle_function_call") as mock_handle_function_call,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("write the report")
|
||||
|
||||
assert result["completed"] is False
|
||||
assert result["partial"] is True
|
||||
assert "truncated due to output length limit" in result["error"]
|
||||
mock_handle_function_call.assert_not_called()
|
||||
|
||||
def test_truncated_tool_call_retries_once_before_refusing(self, agent):
|
||||
"""When tool call args are truncated, the agent retries the API call
|
||||
once. If the retry succeeds (valid JSON args), tool execution proceeds."""
|
||||
self._setup_agent(agent)
|
||||
agent.valid_tool_names.add("write_file")
|
||||
bad_tc = _mock_tool_call(
|
||||
name="write_file",
|
||||
arguments='{"path":"report.md","content":"partial',
|
||||
call_id="c1",
|
||||
)
|
||||
truncated_resp = _mock_response(
|
||||
content="", finish_reason="length", tool_calls=[bad_tc],
|
||||
)
|
||||
good_tc = _mock_tool_call(
|
||||
name="write_file",
|
||||
arguments='{"path":"report.md","content":"full content"}',
|
||||
call_id="c2",
|
||||
)
|
||||
good_resp = _mock_response(
|
||||
content="", finish_reason="stop", tool_calls=[good_tc],
|
||||
)
|
||||
with (
|
||||
patch("run_agent.handle_function_call", return_value='{"success":true}') as mock_hfc,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
# First call: truncated → retry. Second: valid → execute tool.
|
||||
# Third: final text response.
|
||||
final_resp = _mock_response(content="Done!", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [
|
||||
truncated_resp, good_resp, final_resp,
|
||||
]
|
||||
result = agent.run_conversation("write the report")
|
||||
|
||||
# Tool was executed on the retry (good_resp)
|
||||
mock_hfc.assert_called_once()
|
||||
assert result["final_response"] == "Done!"
|
||||
|
||||
|
||||
class TestRetryExhaustion:
|
||||
"""Regression: retry_count > max_retries was dead code (off-by-one).
|
||||
@@ -3144,20 +3010,6 @@ class TestStreamingApiCall:
|
||||
assert tc[0].function.name == "search"
|
||||
assert tc[1].function.name == "read"
|
||||
|
||||
def test_truncated_tool_call_args_upgrade_finish_reason_to_length(self, agent):
|
||||
chunks = [
|
||||
_make_chunk(tool_calls=[_make_tc_delta(0, "call_1", "write_file", '{"path":"x.txt","content":"hel')]),
|
||||
]
|
||||
agent.client.chat.completions.create.return_value = iter(chunks)
|
||||
|
||||
resp = agent._interruptible_streaming_api_call({"messages": []})
|
||||
|
||||
tc = resp.choices[0].message.tool_calls
|
||||
assert len(tc) == 1
|
||||
assert tc[0].function.name == "write_file"
|
||||
assert tc[0].function.arguments == '{"path":"x.txt","content":"hel'
|
||||
assert resp.choices[0].finish_reason == "length"
|
||||
|
||||
def test_ollama_reused_index_separate_tool_calls(self, agent):
|
||||
"""Ollama sends every tool call at index 0 with different ids.
|
||||
|
||||
|
||||
@@ -648,15 +648,6 @@ def test_preflight_codex_api_kwargs_allows_reasoning_and_temperature(monkeypatch
|
||||
assert result["max_output_tokens"] == 4096
|
||||
|
||||
|
||||
def test_preflight_codex_api_kwargs_allows_service_tier(monkeypatch):
|
||||
agent = _build_agent(monkeypatch)
|
||||
kwargs = _codex_request_kwargs()
|
||||
kwargs["service_tier"] = "priority"
|
||||
|
||||
result = agent._preflight_codex_api_kwargs(kwargs)
|
||||
assert result["service_tier"] == "priority"
|
||||
|
||||
|
||||
def test_run_conversation_codex_replay_payload_keeps_call_id(monkeypatch):
|
||||
agent = _build_agent(monkeypatch)
|
||||
responses = [_codex_tool_call_response(), _codex_message_response("done")]
|
||||
|
||||
@@ -211,15 +211,14 @@ class TestExchangeAuthCode:
|
||||
assert setup_module.PENDING_AUTH_PATH.exists()
|
||||
assert not setup_module.TOKEN_PATH.exists()
|
||||
|
||||
def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys):
|
||||
"""Partial scopes are accepted with a warning (gws migration: v2.0)."""
|
||||
def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_module, capsys):
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "***", "scopes": setup_module.SCOPES}))
|
||||
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "existing-token", "scopes": setup_module.SCOPES}))
|
||||
FakeFlow.credentials_payload = {
|
||||
"token": "***",
|
||||
"refresh_token": "***",
|
||||
"token": "narrow-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
@@ -229,12 +228,10 @@ class TestExchangeAuthCode:
|
||||
],
|
||||
}
|
||||
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
with pytest.raises(SystemExit):
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "warning" in out.lower()
|
||||
assert "missing" in out.lower()
|
||||
# Token is saved (partial scopes accepted)
|
||||
assert setup_module.TOKEN_PATH.exists()
|
||||
# Pending auth is cleaned up
|
||||
assert not setup_module.PENDING_AUTH_PATH.exists()
|
||||
assert "refusing to save incomplete google workspace token" in out.lower()
|
||||
assert json.loads(setup_module.TOKEN_PATH.read_text())["token"] == "existing-token"
|
||||
assert setup_module.PENDING_AUTH_PATH.exists()
|
||||
|
||||
@@ -1,175 +1,117 @@
|
||||
"""Tests for Google Workspace gws bridge and CLI wrapper."""
|
||||
"""Regression tests for Google Workspace API credential validation."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BRIDGE_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/gws_bridge.py"
|
||||
)
|
||||
API_PATH = (
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/google_api.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge_module(monkeypatch, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
class FakeAuthorizedCredentials:
|
||||
def __init__(self, *, valid=True, expired=False, refresh_token="refresh-token"):
|
||||
self.valid = valid
|
||||
self.expired = expired
|
||||
self.refresh_token = refresh_token
|
||||
self.refresh_calls = 0
|
||||
|
||||
spec = importlib.util.spec_from_file_location("gws_bridge_test", BRIDGE_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
def refresh(self, _request):
|
||||
self.refresh_calls += 1
|
||||
self.valid = True
|
||||
self.expired = False
|
||||
|
||||
def to_json(self):
|
||||
return json.dumps({
|
||||
"token": "refreshed-token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents.readonly",
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
class FakeCredentialsFactory:
|
||||
creds = FakeAuthorizedCredentials()
|
||||
|
||||
@classmethod
|
||||
def from_authorized_user_file(cls, _path, _scopes):
|
||||
return cls.creds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_module(monkeypatch, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
def google_api_module(monkeypatch, tmp_path):
|
||||
google_module = types.ModuleType("google")
|
||||
oauth2_module = types.ModuleType("google.oauth2")
|
||||
credentials_module = types.ModuleType("google.oauth2.credentials")
|
||||
credentials_module.Credentials = FakeCredentialsFactory
|
||||
auth_module = types.ModuleType("google.auth")
|
||||
transport_module = types.ModuleType("google.auth.transport")
|
||||
requests_module = types.ModuleType("google.auth.transport.requests")
|
||||
requests_module.Request = object
|
||||
|
||||
spec = importlib.util.spec_from_file_location("gws_api_test", API_PATH)
|
||||
monkeypatch.setitem(sys.modules, "google", google_module)
|
||||
monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module)
|
||||
monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth", auth_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("google_workspace_api_test", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json")
|
||||
return module
|
||||
|
||||
|
||||
def _write_token(path: Path, *, token="ya29.test", expiry=None, **extra):
|
||||
data = {
|
||||
"token": token,
|
||||
"refresh_token": "1//refresh",
|
||||
"client_id": "123.apps.googleusercontent.com",
|
||||
"client_secret": "secret",
|
||||
def _write_token(path: Path, scopes):
|
||||
path.write_text(json.dumps({
|
||||
"token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
**extra,
|
||||
}
|
||||
if expiry is not None:
|
||||
data["expiry"] = expiry
|
||||
path.write_text(json.dumps(data))
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": scopes,
|
||||
}))
|
||||
|
||||
|
||||
def test_bridge_returns_valid_token(bridge_module, tmp_path):
|
||||
"""Non-expired token is returned without refresh."""
|
||||
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
token_path = bridge_module.get_token_path()
|
||||
_write_token(token_path, token="ya29.valid", expiry=future)
|
||||
def test_get_credentials_rejects_missing_scopes(google_api_module, capsys):
|
||||
FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)
|
||||
_write_token(google_api_module.TOKEN_PATH, [
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
])
|
||||
|
||||
result = bridge_module.get_valid_token()
|
||||
assert result == "ya29.valid"
|
||||
|
||||
|
||||
def test_bridge_refreshes_expired_token(bridge_module, tmp_path):
|
||||
"""Expired token triggers a refresh via token_uri."""
|
||||
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
token_path = bridge_module.get_token_path()
|
||||
_write_token(token_path, token="ya29.old", expiry=past)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({
|
||||
"access_token": "ya29.refreshed",
|
||||
"expires_in": 3600,
|
||||
}).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
result = bridge_module.get_valid_token()
|
||||
|
||||
assert result == "ya29.refreshed"
|
||||
# Verify persisted
|
||||
saved = json.loads(token_path.read_text())
|
||||
assert saved["token"] == "ya29.refreshed"
|
||||
|
||||
|
||||
def test_bridge_exits_on_missing_token(bridge_module):
|
||||
"""Missing token file causes exit with code 1."""
|
||||
with pytest.raises(SystemExit):
|
||||
bridge_module.get_valid_token()
|
||||
google_api_module.get_credentials()
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "missing google workspace scopes" in err.lower()
|
||||
assert "gmail.send" in err
|
||||
|
||||
|
||||
def test_bridge_main_injects_token_env(bridge_module, tmp_path):
|
||||
"""main() sets GOOGLE_WORKSPACE_CLI_TOKEN in subprocess env."""
|
||||
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
token_path = bridge_module.get_token_path()
|
||||
_write_token(token_path, token="ya29.injected", expiry=future)
|
||||
def test_get_credentials_accepts_full_scope_token(google_api_module):
|
||||
FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)
|
||||
_write_token(google_api_module.TOKEN_PATH, list(google_api_module.SCOPES))
|
||||
|
||||
captured = {}
|
||||
creds = google_api_module.get_credentials()
|
||||
|
||||
def capture_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["env"] = kwargs.get("env", {})
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch.object(sys, "argv", ["gws_bridge.py", "gmail", "+triage"]):
|
||||
with patch.object(subprocess, "run", side_effect=capture_run):
|
||||
with pytest.raises(SystemExit):
|
||||
bridge_module.main()
|
||||
|
||||
assert captured["env"]["GOOGLE_WORKSPACE_CLI_TOKEN"] == "ya29.injected"
|
||||
assert captured["cmd"] == ["gws", "gmail", "+triage"]
|
||||
|
||||
|
||||
def test_api_calendar_list_uses_agenda_by_default(api_module):
|
||||
"""calendar list without dates uses +agenda helper."""
|
||||
captured = {}
|
||||
|
||||
def capture_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
args = api_module.argparse.Namespace(
|
||||
start="", end="", max=25, calendar="primary", func=api_module.calendar_list,
|
||||
)
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture_run):
|
||||
with pytest.raises(SystemExit):
|
||||
api_module.calendar_list(args)
|
||||
|
||||
gws_args = captured["cmd"][2:] # skip python + bridge path
|
||||
assert "calendar" in gws_args
|
||||
assert "+agenda" in gws_args
|
||||
assert "--days" in gws_args
|
||||
|
||||
|
||||
def test_api_calendar_list_respects_date_range(api_module):
|
||||
"""calendar list with --start/--end uses raw events list API."""
|
||||
captured = {}
|
||||
|
||||
def capture_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
args = api_module.argparse.Namespace(
|
||||
start="2026-04-01T00:00:00Z",
|
||||
end="2026-04-07T23:59:59Z",
|
||||
max=25,
|
||||
calendar="primary",
|
||||
func=api_module.calendar_list,
|
||||
)
|
||||
|
||||
with patch.object(subprocess, "run", side_effect=capture_run):
|
||||
with pytest.raises(SystemExit):
|
||||
api_module.calendar_list(args)
|
||||
|
||||
gws_args = captured["cmd"][2:]
|
||||
assert "events" in gws_args
|
||||
assert "list" in gws_args
|
||||
params_idx = gws_args.index("--params")
|
||||
params = json.loads(gws_args[params_idx + 1])
|
||||
assert params["timeMin"] == "2026-04-01T00:00:00Z"
|
||||
assert params["timeMax"] == "2026-04-07T23:59:59Z"
|
||||
assert creds is FakeCredentialsFactory.creds
|
||||
|
||||
@@ -63,4 +63,4 @@ class TestCamofoxConfigDefaults:
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
# managed_persistence is auto-merged by _deep_merge, no version bump needed
|
||||
assert DEFAULT_CONFIG["_config_version"] == 13
|
||||
assert DEFAULT_CONFIG["_config_version"] == 12
|
||||
|
||||
@@ -13,7 +13,6 @@ from tools.browser_tool import (
|
||||
_find_agent_browser,
|
||||
_run_browser_command,
|
||||
_SANE_PATH,
|
||||
check_browser_requirements,
|
||||
)
|
||||
|
||||
|
||||
@@ -150,31 +149,6 @@ class TestFindAgentBrowser:
|
||||
_find_agent_browser()
|
||||
|
||||
|
||||
class TestBrowserRequirements:
|
||||
def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False)
|
||||
monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
|
||||
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
|
||||
|
||||
assert check_browser_requirements() is False
|
||||
|
||||
|
||||
class TestRunBrowserCommandTermuxFallback:
|
||||
def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
|
||||
monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
|
||||
|
||||
result = _run_browser_command("task-1", "navigate", ["https://example.com"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "bare npx fallback" in result["error"]
|
||||
assert "agent-browser install" in result["error"]
|
||||
|
||||
|
||||
class TestRunBrowserCommandPathConstruction:
|
||||
"""Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH."""
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ from tools.code_execution_tool import (
|
||||
build_execute_code_schema,
|
||||
EXECUTE_CODE_SCHEMA,
|
||||
_TOOL_DOC_LINES,
|
||||
_execute_remote,
|
||||
)
|
||||
|
||||
|
||||
@@ -116,48 +115,6 @@ class TestHermesToolsGeneration(unittest.TestCase):
|
||||
self.assertIn("def retry(", src)
|
||||
self.assertIn("import json, os, socket, shlex, time", src)
|
||||
|
||||
def test_file_transport_uses_tempfile_fallback_for_rpc_dir(self):
|
||||
src = generate_hermes_tools_module(["terminal"], transport="file")
|
||||
self.assertIn("import json, os, shlex, tempfile, time", src)
|
||||
self.assertIn("os.path.join(tempfile.gettempdir(), \"hermes_rpc\")", src)
|
||||
self.assertNotIn('os.environ.get("HERMES_RPC_DIR", "/tmp/hermes_rpc")', src)
|
||||
|
||||
|
||||
class TestExecuteCodeRemoteTempDir(unittest.TestCase):
|
||||
def test_execute_remote_uses_backend_temp_dir_for_sandbox(self):
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
|
||||
def get_temp_dir(self):
|
||||
return "/data/data/com.termux/files/usr/tmp"
|
||||
|
||||
def execute(self, command, cwd=None, timeout=None):
|
||||
self.commands.append((command, cwd, timeout))
|
||||
if "command -v python3" in command:
|
||||
return {"output": "OK\n"}
|
||||
if "python3 script.py" in command:
|
||||
return {"output": "hello\n", "returncode": 0}
|
||||
return {"output": ""}
|
||||
|
||||
env = FakeEnv()
|
||||
fake_thread = MagicMock()
|
||||
|
||||
with patch("tools.code_execution_tool._load_config", return_value={"timeout": 30, "max_tool_calls": 5}), \
|
||||
patch("tools.code_execution_tool._get_or_create_env", return_value=(env, "ssh")), \
|
||||
patch("tools.code_execution_tool._ship_file_to_remote"), \
|
||||
patch("tools.code_execution_tool.threading.Thread", return_value=fake_thread):
|
||||
result = json.loads(_execute_remote("print('hello')", "task-1", ["terminal"]))
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
mkdir_cmd = env.commands[1][0]
|
||||
run_cmd = next(cmd for cmd, _, _ in env.commands if "python3 script.py" in cmd)
|
||||
cleanup_cmd = env.commands[-1][0]
|
||||
self.assertIn("mkdir -p /data/data/com.termux/files/usr/tmp/hermes_exec_", mkdir_cmd)
|
||||
self.assertIn("HERMES_RPC_DIR=/data/data/com.termux/files/usr/tmp/hermes_exec_", run_cmd)
|
||||
self.assertIn("rm -rf /data/data/com.termux/files/usr/tmp/hermes_exec_", cleanup_cmd)
|
||||
self.assertNotIn("mkdir -p /tmp/hermes_exec_", mkdir_cmd)
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "UDS not available on Windows")
|
||||
class TestExecuteCode(unittest.TestCase):
|
||||
|
||||
@@ -258,30 +258,28 @@ def _make_execute_only_env(forward_env=None):
|
||||
|
||||
def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch):
|
||||
"""_build_init_env_args picks up forwarded env vars from .env file at init time."""
|
||||
# Use a var that is NOT in _HERMES_PROVIDER_ENV_BLOCKLIST (GITHUB_TOKEN
|
||||
# is in the copilot provider's api_key_env_vars and gets stripped).
|
||||
env = _make_execute_only_env(["DATABASE_URL"])
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "DATABASE_URL=value_from_dotenv" in args_str
|
||||
assert "GITHUB_TOKEN=value_from_dotenv" in args_str
|
||||
|
||||
|
||||
def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch):
|
||||
"""Shell env vars take priority over .env file values in init env args."""
|
||||
env = _make_execute_only_env(["DATABASE_URL"])
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", "value_from_shell")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "value_from_shell")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "DATABASE_URL=value_from_shell" in args_str
|
||||
assert "GITHUB_TOKEN=value_from_shell" in args_str
|
||||
assert "value_from_dotenv" not in args_str
|
||||
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
|
||||
class TestLocalTempDir:
|
||||
def test_uses_os_tmpdir_for_session_artifacts(self, monkeypatch):
|
||||
monkeypatch.setenv("TMPDIR", "/data/data/com.termux/files/usr/tmp")
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(cwd=".", timeout=10)
|
||||
|
||||
assert env.get_temp_dir() == "/data/data/com.termux/files/usr/tmp"
|
||||
assert env._snapshot_path == f"/data/data/com.termux/files/usr/tmp/hermes-snap-{env._session_id}.sh"
|
||||
assert env._cwd_file == f"/data/data/com.termux/files/usr/tmp/hermes-cwd-{env._session_id}.txt"
|
||||
|
||||
def test_prefers_backend_env_tmpdir_override(self, monkeypatch):
|
||||
monkeypatch.delenv("TMPDIR", raising=False)
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(
|
||||
cwd=".",
|
||||
timeout=10,
|
||||
env={"TMPDIR": "/data/data/com.termux/files/home/.cache/hermes-tmp/"},
|
||||
)
|
||||
|
||||
assert env.get_temp_dir() == "/data/data/com.termux/files/home/.cache/hermes-tmp"
|
||||
assert env._snapshot_path == (
|
||||
f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-snap-{env._session_id}.sh"
|
||||
)
|
||||
assert env._cwd_file == (
|
||||
f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-cwd-{env._session_id}.txt"
|
||||
)
|
||||
|
||||
def test_falls_back_to_tempfile_when_tmp_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("TMPDIR", raising=False)
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch("tools.environments.local.os.path.isdir", return_value=False), \
|
||||
patch("tools.environments.local.os.access", return_value=False), \
|
||||
patch("tools.environments.local.tempfile.gettempdir", return_value="/cache/tmp"), \
|
||||
patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(cwd=".", timeout=10)
|
||||
assert env.get_temp_dir() == "/cache/tmp"
|
||||
assert env._snapshot_path == f"/cache/tmp/hermes-snap-{env._session_id}.sh"
|
||||
assert env._cwd_file == f"/cache/tmp/hermes-cwd-{env._session_id}.txt"
|
||||
@@ -147,7 +147,7 @@ class TestBaseEnvCompatibility:
|
||||
"""Hermes wires parser selection through ServerManager.tool_parser."""
|
||||
import ast
|
||||
|
||||
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
tree = ast.parse(source)
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestBaseEnvCompatibility:
|
||||
|
||||
def test_hermes_base_env_uses_config_tool_call_parser(self):
|
||||
"""Verify hermes_base_env uses the config field rather than a local parser instance."""
|
||||
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
|
||||
assert 'tool_call_parser: str = Field(' in source
|
||||
|
||||
@@ -135,64 +135,6 @@ class TestReadLog:
|
||||
assert "5 lines" in result["showing"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Stdin helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestStdinHelpers:
|
||||
def test_close_stdin_not_found(self, registry):
|
||||
result = registry.close_stdin("nonexistent")
|
||||
assert result["status"] == "not_found"
|
||||
|
||||
def test_close_stdin_pipe_mode(self, registry):
|
||||
proc = MagicMock()
|
||||
proc.stdin = MagicMock()
|
||||
s = _make_session()
|
||||
s.process = proc
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
proc.stdin.close.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_pty_mode(self, registry):
|
||||
pty = MagicMock()
|
||||
s = _make_session()
|
||||
s._pty = pty
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
pty.sendeof.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path):
|
||||
session = registry.spawn_local(
|
||||
'python3 -c "import sys; print(sys.stdin.read().strip())"',
|
||||
cwd=str(tmp_path),
|
||||
use_pty=False,
|
||||
)
|
||||
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
assert registry.submit_stdin(session.id, "hello")["status"] == "ok"
|
||||
assert registry.close_stdin(session.id)["status"] == "ok"
|
||||
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
poll = registry.poll(session.id)
|
||||
if poll["status"] == "exited":
|
||||
assert poll["exit_code"] == 0
|
||||
assert "hello" in poll["output_preview"]
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
pytest.fail("process did not exit after stdin was closed")
|
||||
finally:
|
||||
registry.kill_process(session.id)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# List sessions
|
||||
# =========================================================================
|
||||
@@ -340,67 +282,6 @@ class TestSpawnEnvSanitization:
|
||||
assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN" not in env
|
||||
assert env["PYTHONUNBUFFERED"] == "1"
|
||||
|
||||
def test_spawn_via_env_uses_backend_temp_dir_for_artifacts(self, registry):
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
|
||||
def get_temp_dir(self):
|
||||
return "/data/data/com.termux/files/usr/tmp"
|
||||
|
||||
def execute(self, command, timeout=None):
|
||||
self.commands.append((command, timeout))
|
||||
return {"output": "4321\n"}
|
||||
|
||||
env = FakeEnv()
|
||||
fake_thread = MagicMock()
|
||||
|
||||
with patch("tools.process_registry.threading.Thread", return_value=fake_thread), \
|
||||
patch.object(registry, "_write_checkpoint"):
|
||||
session = registry.spawn_via_env(env, "echo hello")
|
||||
|
||||
bg_command = env.commands[0][0]
|
||||
assert session.pid == 4321
|
||||
assert "/data/data/com.termux/files/usr/tmp/hermes_bg_" in bg_command
|
||||
assert ".exit" in bg_command
|
||||
assert "rc=$?;" in bg_command
|
||||
assert " > /tmp/hermes_bg_" not in bg_command
|
||||
assert "cat /tmp/hermes_bg_" not in bg_command
|
||||
fake_thread.start.assert_called_once()
|
||||
|
||||
def test_env_poller_quotes_temp_paths_with_spaces(self, registry):
|
||||
session = _make_session(sid="proc_space")
|
||||
session.exited = False
|
||||
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
self._responses = iter([
|
||||
{"output": "hello\n"},
|
||||
{"output": "1\n"},
|
||||
{"output": "0\n"},
|
||||
])
|
||||
|
||||
def execute(self, command, timeout=None):
|
||||
self.commands.append((command, timeout))
|
||||
return next(self._responses)
|
||||
|
||||
env = FakeEnv()
|
||||
|
||||
with patch("tools.process_registry.time.sleep", return_value=None), \
|
||||
patch.object(registry, "_move_to_finished"):
|
||||
registry._env_poller_loop(
|
||||
session,
|
||||
env,
|
||||
"/path with spaces/hermes_bg.log",
|
||||
"/path with spaces/hermes_bg.pid",
|
||||
"/path with spaces/hermes_bg.exit",
|
||||
)
|
||||
|
||||
assert env.commands[0][0] == "cat '/path with spaces/hermes_bg.log' 2>/dev/null"
|
||||
assert env.commands[1][0] == "kill -0 \"$(cat '/path with spaces/hermes_bg.pid' 2>/dev/null)\" 2>/dev/null; echo $?"
|
||||
assert env.commands[2][0] == "cat '/path with spaces/hermes_bg.exit' 2>/dev/null"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Checkpoint
|
||||
|
||||
@@ -125,9 +125,7 @@ class TestSendMatrix:
|
||||
url = call_kwargs[0][0]
|
||||
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/")
|
||||
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
||||
payload = call_kwargs[1]["json"]
|
||||
assert payload["msgtype"] == "m.text"
|
||||
assert payload["body"] == "hello matrix"
|
||||
assert call_kwargs[1]["json"] == {"msgtype": "m.text", "body": "hello matrix"}
|
||||
|
||||
def test_http_error(self):
|
||||
resp = _make_aiohttp_resp(403, text_data="Forbidden")
|
||||
|
||||
@@ -32,30 +32,6 @@ def _install_telegram_mock(monkeypatch, bot):
|
||||
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
|
||||
|
||||
|
||||
def _ensure_slack_mock(monkeypatch):
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
monkeypatch.setitem(sys.modules, name, mod)
|
||||
|
||||
|
||||
class TestSendMessageTool:
|
||||
def test_cron_duplicate_target_is_skipped_and_explained(self):
|
||||
home = SimpleNamespace(chat_id="-1001")
|
||||
@@ -450,7 +426,7 @@ class TestSendToPlatformChunking:
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
SimpleNamespace(enabled=True, token="tok", extra={}),
|
||||
"ch", long_msg,
|
||||
)
|
||||
)
|
||||
@@ -459,115 +435,8 @@ class TestSendToPlatformChunking:
|
||||
for call in send.await_args_list:
|
||||
assert len(call.args[2]) <= 2020 # each chunk fits the limit
|
||||
|
||||
def test_slack_messages_are_formatted_before_send(self, monkeypatch):
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"**hello** from [Hermes](<https://example.com>)",
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
send.assert_awaited_once_with(
|
||||
"***",
|
||||
"C123",
|
||||
"*hello* from <https://example.com|Hermes>",
|
||||
)
|
||||
|
||||
def test_slack_bold_italic_formatted_before_send(self, monkeypatch):
|
||||
"""Bold+italic ***text*** survives tool-layer formatting."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"***important*** update",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "*_important_*" in sent_text
|
||||
|
||||
def test_slack_blockquote_formatted_before_send(self, monkeypatch):
|
||||
"""Blockquote '>' markers must survive formatting (not escaped to '>')."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"> important quote\n\nnormal text & stuff",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert sent_text.startswith("> important quote")
|
||||
assert "&" in sent_text # & is escaped
|
||||
assert ">" not in sent_text.split("\n")[0] # > in blockquote is NOT escaped
|
||||
|
||||
def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):
|
||||
"""Pre-escaped HTML entities survive tool-layer formatting without double-escaping."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"AT&T <tag> test",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "&amp;" not in sent_text
|
||||
assert "&lt;" not in sent_text
|
||||
assert "AT&T" in sent_text
|
||||
|
||||
def test_slack_url_with_parens_formatted_before_send(self, monkeypatch):
|
||||
"""Wikipedia-style URL with parens survives tool-layer formatting."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in sent_text
|
||||
|
||||
def test_telegram_media_attaches_to_last_chunk(self):
|
||||
|
||||
"""When chunked, media files are sent only with the last chunk."""
|
||||
sent_calls = []
|
||||
|
||||
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import tools.terminal_tool as terminal_tool_module
|
||||
from tools import process_registry as process_registry_module
|
||||
|
||||
|
||||
def _base_config(tmp_path):
|
||||
return {
|
||||
"env_type": "local",
|
||||
"docker_image": "",
|
||||
"singularity_image": "",
|
||||
"modal_image": "",
|
||||
"daytona_image": "",
|
||||
"cwd": str(tmp_path),
|
||||
"timeout": 30,
|
||||
}
|
||||
|
||||
|
||||
def test_command_requires_pipe_stdin_detects_gh_with_token():
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --hostname github.com --git-protocol https --with-token"
|
||||
) is True
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --web"
|
||||
) is False
|
||||
|
||||
|
||||
def test_terminal_background_disables_pty_for_gh_with_token(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="gh auth login --hostname github.com --git-protocol https --with-token",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is False
|
||||
assert result["session_id"] == "proc_test"
|
||||
assert "PTY disabled" in result["pty_note"]
|
||||
|
||||
|
||||
def test_terminal_background_keeps_pty_for_regular_interactive_commands(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="python3 -c \"print(input())\"",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is True
|
||||
assert "pty_note" not in result
|
||||
@@ -16,7 +16,6 @@ from tools.tool_result_storage import (
|
||||
STORAGE_DIR,
|
||||
_build_persisted_message,
|
||||
_heredoc_marker,
|
||||
_resolve_storage_dir,
|
||||
_write_to_sandbox,
|
||||
enforce_turn_budget,
|
||||
generate_preview,
|
||||
@@ -116,24 +115,6 @@ class TestWriteToSandbox:
|
||||
_write_to_sandbox("content", "/tmp/hermes-results/abc.txt", env)
|
||||
assert env.execute.call_args[1]["timeout"] == 30
|
||||
|
||||
def test_uses_parent_dir_of_remote_path(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
remote_path = "/data/data/com.termux/files/usr/tmp/hermes-results/abc.txt"
|
||||
_write_to_sandbox("content", remote_path, env)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd
|
||||
|
||||
|
||||
class TestResolveStorageDir:
|
||||
def test_defaults_to_storage_dir_without_env(self):
|
||||
assert _resolve_storage_dir(None) == STORAGE_DIR
|
||||
|
||||
def test_uses_env_temp_dir_when_available(self):
|
||||
env = MagicMock()
|
||||
env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp"
|
||||
assert _resolve_storage_dir(env) == "/data/data/com.termux/files/usr/tmp/hermes-results"
|
||||
|
||||
|
||||
# ── _build_persisted_message ──────────────────────────────────────────
|
||||
|
||||
@@ -360,22 +341,6 @@ class TestMaybePersistToolResult:
|
||||
)
|
||||
assert "DISTINCTIVE_START_MARKER" in result
|
||||
|
||||
def test_env_temp_dir_changes_persisted_path(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp"
|
||||
content = "x" * 60_000
|
||||
result = maybe_persist_tool_result(
|
||||
content=content,
|
||||
tool_name="terminal",
|
||||
tool_use_id="tc_termux",
|
||||
env=env,
|
||||
threshold=30_000,
|
||||
)
|
||||
assert "/data/data/com.termux/files/usr/tmp/hermes-results/tc_termux.txt" in result
|
||||
cmd = env.execute.call_args[0][0]
|
||||
assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd
|
||||
|
||||
def test_threshold_zero_forces_persist(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
|
||||
@@ -30,10 +30,7 @@ class TestValidateImageUrl:
|
||||
"""Tests for URL validation, including urlparse-based netloc check."""
|
||||
|
||||
def test_valid_https_url(self):
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
|
||||
def test_valid_http_url(self):
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
@@ -59,16 +56,10 @@ class TestValidateImageUrl:
|
||||
assert _validate_image_url("http://localhost:8080/image.png") is False
|
||||
|
||||
def test_valid_url_with_port(self):
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
|
||||
def test_valid_url_with_path_only(self):
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
|
||||
def test_rejects_empty_string(self):
|
||||
assert _validate_image_url("") is False
|
||||
@@ -450,11 +441,6 @@ class TestVisionRequirements:
|
||||
(tmp_path / "auth.json").write_text(
|
||||
'{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token":"codex-access-token","refresh_token":"codex-refresh-token"}}}}'
|
||||
)
|
||||
# config.yaml must reference the codex provider so vision auto-detect
|
||||
# falls back to the active provider via _read_main_provider().
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
'model:\n default: gpt-4o\n provider: openai-codex\n'
|
||||
)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
@@ -183,77 +183,12 @@ class TestDetectAudioEnvironment:
|
||||
assert result["available"] is False
|
||||
assert any("PortAudio" in w for w in result["warnings"])
|
||||
|
||||
def test_termux_import_error_shows_termux_install_guidance(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: None)
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is False
|
||||
assert any("pkg install python-numpy portaudio" in w for w in result["warnings"])
|
||||
assert any("python -m pip install sounddevice" in w for w in result["warnings"])
|
||||
|
||||
def test_termux_api_package_without_android_app_blocks_voice(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is False
|
||||
assert any("Termux:API Android app is not installed" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode.shutil.which", lambda cmd: "/data/data/com.termux/files/usr/bin/termux-microphone-record" if cmd == "termux-microphone-record" else None)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is True
|
||||
assert any("Termux:API microphone recording available" in n for n in result.get("notices", []))
|
||||
assert result["warnings"] == []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# check_voice_requirements
|
||||
# ============================================================================
|
||||
|
||||
class TestCheckVoiceRequirements:
|
||||
def test_termux_api_capture_counts_as_audio_available(self, monkeypatch):
|
||||
monkeypatch.setattr("tools.voice_mode._audio_available", lambda: False)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]})
|
||||
monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai")
|
||||
|
||||
from tools.voice_mode import check_voice_requirements
|
||||
result = check_voice_requirements()
|
||||
|
||||
assert result["available"] is True
|
||||
assert result["audio_available"] is True
|
||||
assert result["missing_packages"] == []
|
||||
assert "Termux:API microphone" in result["details"]
|
||||
|
||||
def test_all_requirements_met(self, monkeypatch):
|
||||
monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.detect_audio_environment",
|
||||
@@ -300,85 +235,8 @@ class TestCheckVoiceRequirements:
|
||||
# AudioRecorder
|
||||
# ============================================================================
|
||||
|
||||
class TestCreateAudioRecorder:
|
||||
def test_termux_uses_termux_audio_recorder_when_api_present(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
|
||||
from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder
|
||||
recorder = create_audio_recorder()
|
||||
|
||||
assert isinstance(recorder, TermuxAudioRecorder)
|
||||
assert recorder.supports_silence_autostop is False
|
||||
|
||||
def test_termux_without_android_app_falls_back_to_audio_recorder(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False)
|
||||
|
||||
from tools.voice_mode import create_audio_recorder, AudioRecorder
|
||||
recorder = create_audio_recorder()
|
||||
|
||||
assert isinstance(recorder, AudioRecorder)
|
||||
|
||||
|
||||
class TestTermuxAudioRecorder:
|
||||
def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir):
|
||||
command_calls = []
|
||||
output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
command_calls.append(cmd)
|
||||
if cmd[1] == "-f":
|
||||
Path(cmd[2]).write_bytes(b"aac-bytes")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
|
||||
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
|
||||
|
||||
from tools.voice_mode import TermuxAudioRecorder
|
||||
recorder = TermuxAudioRecorder()
|
||||
recorder.start()
|
||||
recorder._start_time = time.monotonic() - 1.0
|
||||
result = recorder.stop()
|
||||
|
||||
assert result == str(output_path)
|
||||
assert command_calls[0][:2] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-f"]
|
||||
assert command_calls[1] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-q"]
|
||||
|
||||
def test_cancel_removes_partial_termux_recording(self, monkeypatch, temp_voice_dir):
|
||||
output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[1] == "-f":
|
||||
Path(cmd[2]).write_bytes(b"aac-bytes")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
|
||||
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
|
||||
|
||||
from tools.voice_mode import TermuxAudioRecorder
|
||||
recorder = TermuxAudioRecorder()
|
||||
recorder.start()
|
||||
recorder.cancel()
|
||||
|
||||
assert output_path.exists() is False
|
||||
assert recorder.is_recording is False
|
||||
|
||||
|
||||
class TestAudioRecorder:
|
||||
def test_start_raises_without_audio_libs(self, monkeypatch):
|
||||
class TestAudioRecorderStart:
|
||||
def test_start_raises_without_audio(self, monkeypatch):
|
||||
def _fail_import():
|
||||
raise ImportError("no sounddevice")
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", _fail_import)
|
||||
|
||||
@@ -225,7 +225,6 @@ class TestWebCrawlTavily:
|
||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||
from tools.web_tools import web_crawl_tool
|
||||
result = json.loads(asyncio.get_event_loop().run_until_complete(
|
||||
@@ -245,7 +244,6 @@ class TestWebCrawlTavily:
|
||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||
patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post, \
|
||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||
from tools.web_tools import web_crawl_tool
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user