Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 415043315f | |||
| 98eb32f39a | |||
| 2df306e6cd | |||
| 79a5f03f92 | |||
| 527ca7d238 | |||
| b11e53e34f | |||
| 1e7a598bac | |||
| 3eddabf53b | |||
| 971542d254 | |||
| 4a95029e6c | |||
| 432614591a | |||
| d45c738a52 | |||
| d50be05b1c | |||
| 24e8a6e701 | |||
| 3a97fb3d47 | |||
| 91d6ea07c8 | |||
| fdcb3e9a4b | |||
| 627abbb1ea | |||
| 39fcf1d127 | |||
| 6172f95944 | |||
| b24d239ce1 | |||
| cd9cd1b159 | |||
| 78e213710c | |||
| 4f4fd21149 | |||
| 7ca2f70055 | |||
| dab36d9511 | |||
| 4c02e4597e | |||
| 51c1d2de16 | |||
| 08cb345e24 | |||
| 9dba75bc38 | |||
| 8f50f2834a | |||
| be99feff1f | |||
| 911f57ad97 | |||
| 5d09474348 | |||
| 33773ed5c6 | |||
| a5b0c7e2ec | |||
| c80cc8557e | |||
| 1df0c812c4 | |||
| b5ec6e8df7 | |||
| d7452af257 | |||
| 48923e5a3d | |||
| f77da7de42 | |||
| 36adcebe6c | |||
| 43de1ca8c2 | |||
| f4612785a4 | |||
| 738d0900fd | |||
| 1c532278ae | |||
| 22afa066f8 | |||
| 5e76c650bb | |||
| 15efb410d0 | |||
| e8cba18f77 | |||
| 48dc8ef1d1 | |||
| 156b358320 | |||
| fa47cbd456 | |||
| 92e4bbc201 | |||
| 85cc12e2bd | |||
| 8b1ff55f53 | |||
| 77f99c4ff4 | |||
| 3d90292eda | |||
| d8cc85dcdc | |||
| 18b29b124a | |||
| a6ffa994cd | |||
| bace220d29 | |||
| d1ce358646 | |||
| 88b6eb9ad1 | |||
| 2f48c58b85 | |||
| e25c319fa3 | |||
| 9357db2844 | |||
| 400b5235b8 | |||
| 73533fc728 | |||
| 74520392f2 | |||
| dcb8c5c67a | |||
| 2c53a3344d | |||
| 7f1c1aa4d9 | |||
| ed5f16323f | |||
| d6d9f10629 | |||
| fa8f0c6fae | |||
| 5eefdd9c02 | |||
| 268a4aa1c1 | |||
| 99af222ecf | |||
| f347315e07 | |||
| b80b400141 | |||
| bf039a9268 | |||
| ec7e92082d | |||
| a4877faf96 | |||
| 85caa5d447 | |||
| eda5ae5a5e | |||
| 563ed0e61f | |||
| e371af1df2 | |||
| ee54e20c29 | |||
| 82fbd4771a | |||
| 30ad507a0f | |||
| dce2b0dfa8 | |||
| f9487ee831 | |||
| e038677ef6 | |||
| effcbc8a6b | |||
| 6209e85e7d | |||
| a2a8092e90 | |||
| 520b8d9002 | |||
| 9c5c8268c6 | |||
| 463fbf1418 | |||
| f41031af3a | |||
| c78a188ddd | |||
| d30ee2e545 | |||
| 36730b90c4 | |||
| 050aabe2d4 | |||
| 64c38cc4d0 | |||
| fa2dbd1bb5 | |||
| 6ad2fab8cf | |||
| a14fb3ab1a | |||
| 2c26a80848 | |||
| d67d12b5df | |||
| 86510477f3 | |||
| ce4214ec94 | |||
| 50387d718e | |||
| aa75d0a90b | |||
| 159061836e | |||
| d70f0f1dc0 | |||
| a3014a4481 | |||
| c345ec9a63 | |||
| 64b61cc24b | |||
| e47537e99d | |||
| 9bd1518425 | |||
| c9c6182839 | |||
| 8152de2a84 | |||
| c03858733d | |||
| 08089738d8 | |||
| 82cce3d26c | |||
| e5114298f0 | |||
| 4c1362884d | |||
| 9ea2d96d73 | |||
| 8db5517b4c | |||
| 54db933667 | |||
| 846b9758d8 | |||
| 142202910e | |||
| db86ed1990 | |||
| 7d8b2eee63 | |||
| 3e96c87f37 | |||
| 98e1396b15 | |||
| 96b0f37001 | |||
| d74eaef5f9 | |||
| b2593c8d4e | |||
| 4009f2edd9 | |||
| c0100dde35 | |||
| 5fbb69989d | |||
| 6f629a0462 | |||
| 02aba4a728 | |||
| b9463e32c6 | |||
| 75221db967 | |||
| 435d86ce36 | |||
| 3e95963bde | |||
| 3445530dbf | |||
| ea83cd91e4 | |||
| 276ef49c96 | |||
| 0dace06db7 | |||
| 953f8fa943 | |||
| 0187de1f67 | |||
| c0df4a0a7f | |||
| 9eb543cafe | |||
| ea0e4c267d | |||
| c47d4eda13 | |||
| 80108104cf | |||
| e826cc42ef | |||
| e710bb1f7f | |||
| 27621ef836 | |||
| 12f9f10f0f | |||
| e67eb7ff4b | |||
| dad53205ea | |||
| 10063e730c | |||
| 5dead0f2a0 | |||
| ec374c0599 | |||
| 9ed6eb0cca |
@@ -14,3 +14,6 @@ node_modules
|
||||
.env
|
||||
|
||||
*.md
|
||||
|
||||
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
|
||||
data/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
/venv/
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
|
||||
+3
-3
@@ -55,10 +55,10 @@ If your skill is specialized, community-contributed, or niche, it's better suite
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Git** | With `--recurse-submodules` support |
|
||||
| **Git** | With `--recurse-submodules` support, and the `git-lfs` extension installed |
|
||||
| **Python 3.11+** | uv will install it if missing |
|
||||
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 18+** | Optional — needed for browser tools and WhatsApp bridge |
|
||||
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
|
||||
|
||||
### Clone and install
|
||||
|
||||
@@ -88,7 +88,7 @@ cp cli-config.yaml.example ~/.hermes/config.yaml
|
||||
touch ~/.hermes/.env
|
||||
|
||||
# Add at minimum an LLM provider key:
|
||||
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env
|
||||
echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# Install system dependencies in one layer, clear APT cache
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git && \
|
||||
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
@@ -50,5 +50,6 @@ RUN uv venv && \
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
ENV HERMES_HOME=/opt/data
|
||||
ENV PATH="/opt/data/.local/bin:${PATH}"
|
||||
VOLUME [ "/opt/data" ]
|
||||
ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ]
|
||||
|
||||
@@ -173,7 +173,6 @@ python -m pytest tests/ -q
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📚 [Skills Hub](https://agentskills.io)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
- 💡 [Discussions](https://github.com/NousResearch/hermes-agent/discussions)
|
||||
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account.
|
||||
|
||||
---
|
||||
|
||||
+64
-108
@@ -17,7 +17,6 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from utils import normalize_proxy_env_vars
|
||||
|
||||
@@ -117,6 +116,63 @@ def _get_anthropic_max_output(model: str) -> int:
|
||||
return best_val
|
||||
|
||||
|
||||
def _resolve_positive_anthropic_max_tokens(value) -> Optional[int]:
|
||||
"""Return ``value`` floored to a positive int, or ``None`` if it is not a
|
||||
finite positive number. Ported from openclaw/openclaw#66664.
|
||||
|
||||
Anthropic's Messages API rejects ``max_tokens`` values that are 0,
|
||||
negative, non-integer, or non-finite with HTTP 400. Python's ``or``
|
||||
idiom (``max_tokens or fallback``) correctly catches ``0`` but lets
|
||||
negative ints and fractional floats (``-1``, ``0.5``) through to the
|
||||
API, producing a user-visible failure instead of a local error.
|
||||
"""
|
||||
# Booleans are a subclass of int — exclude explicitly so ``True`` doesn't
|
||||
# silently become 1 and ``False`` doesn't become 0.
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if not isinstance(value, (int, float)):
|
||||
return None
|
||||
try:
|
||||
import math
|
||||
if not math.isfinite(value):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
floored = int(value) # truncates toward zero for floats
|
||||
return floored if floored > 0 else None
|
||||
|
||||
|
||||
def _resolve_anthropic_messages_max_tokens(
|
||||
requested,
|
||||
model: str,
|
||||
context_length: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Resolve the ``max_tokens`` budget for an Anthropic Messages call.
|
||||
|
||||
Prefers ``requested`` when it is a positive finite number; otherwise
|
||||
falls back to the model's output ceiling. Raises ``ValueError`` if no
|
||||
positive budget can be resolved (should not happen with current model
|
||||
table defaults, but guards against a future regression where
|
||||
``_get_anthropic_max_output`` could return ``0``).
|
||||
|
||||
Separately, callers apply a context-window clamp — this resolver does
|
||||
not, to keep the positive-value contract independent of endpoint
|
||||
specifics.
|
||||
|
||||
Ported from openclaw/openclaw#66664 (resolveAnthropicMessagesMaxTokens).
|
||||
"""
|
||||
resolved = _resolve_positive_anthropic_max_tokens(requested)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
fallback = _get_anthropic_max_output(model)
|
||||
if fallback > 0:
|
||||
return fallback
|
||||
raise ValueError(
|
||||
f"Anthropic Messages adapter requires a positive max_tokens value for "
|
||||
f"model {model!r}; got {requested!r} and no model default resolved."
|
||||
)
|
||||
|
||||
|
||||
def _supports_adaptive_thinking(model: str) -> bool:
|
||||
"""Return True for Claude 4.6+ models that support adaptive thinking."""
|
||||
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
|
||||
@@ -301,7 +357,7 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
def build_anthropic_client(api_key: str, base_url: str = None, timeout: float = None):
|
||||
def build_anthropic_client(api_key: str, base_url: str = None, timeout: Optional[float] = None):
|
||||
"""Create an Anthropic client, auto-detecting setup-tokens vs API keys.
|
||||
|
||||
If *timeout* is provided it overrides the default 900s read timeout. The
|
||||
@@ -1391,7 +1447,12 @@ def build_anthropic_kwargs(
|
||||
|
||||
model = normalize_model_name(model, preserve_dots=preserve_dots)
|
||||
# effective_max_tokens = output cap for this call (≠ total context window)
|
||||
effective_max_tokens = max_tokens or _get_anthropic_max_output(model)
|
||||
# Use the resolver helper so non-positive values (negative ints,
|
||||
# fractional floats, NaN, non-numeric) fail locally with a clear error
|
||||
# rather than 400-ing at the Anthropic API. See openclaw/openclaw#66664.
|
||||
effective_max_tokens = _resolve_anthropic_messages_max_tokens(
|
||||
max_tokens, model, context_length=context_length
|
||||
)
|
||||
|
||||
# Clamp output cap to fit inside the total context window.
|
||||
# Only matters for small custom endpoints where context_length < native
|
||||
@@ -1537,109 +1598,4 @@ def build_anthropic_kwargs(
|
||||
return kwargs
|
||||
|
||||
|
||||
def normalize_anthropic_response(
|
||||
response,
|
||||
strip_tool_prefix: bool = False,
|
||||
) -> Tuple[SimpleNamespace, str]:
|
||||
"""Normalize Anthropic response to match the shape expected by AIAgent.
|
||||
|
||||
Returns (assistant_message, finish_reason) where assistant_message has
|
||||
.content, .tool_calls, and .reasoning attributes.
|
||||
|
||||
When *strip_tool_prefix* is True, removes the ``mcp_`` prefix that was
|
||||
added to tool names for OAuth Claude Code compatibility.
|
||||
"""
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
reasoning_details = []
|
||||
tool_calls = []
|
||||
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
elif block.type == "thinking":
|
||||
reasoning_parts.append(block.thinking)
|
||||
block_dict = _to_plain_data(block)
|
||||
if isinstance(block_dict, dict):
|
||||
reasoning_details.append(block_dict)
|
||||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_TOOL_PREFIX):
|
||||
name = name[len(_MCP_TOOL_PREFIX):]
|
||||
tool_calls.append(
|
||||
SimpleNamespace(
|
||||
id=block.id,
|
||||
type="function",
|
||||
function=SimpleNamespace(
|
||||
name=name,
|
||||
arguments=json.dumps(block.input),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Map Anthropic stop_reason to OpenAI finish_reason.
|
||||
# Newer stop reasons added in Claude 4.5+ / 4.7:
|
||||
# - refusal: the model declined to answer (cyber safeguards, CSAM, etc.)
|
||||
# - model_context_window_exceeded: hit context limit (not max_tokens)
|
||||
# Both need distinct handling upstream — a refusal should surface to the
|
||||
# user with a clear message, and a context-window overflow should trigger
|
||||
# compression/truncation rather than be treated as normal end-of-turn.
|
||||
stop_reason_map = {
|
||||
"end_turn": "stop",
|
||||
"tool_use": "tool_calls",
|
||||
"max_tokens": "length",
|
||||
"stop_sequence": "stop",
|
||||
"refusal": "content_filter",
|
||||
"model_context_window_exceeded": "length",
|
||||
}
|
||||
finish_reason = stop_reason_map.get(response.stop_reason, "stop")
|
||||
|
||||
return (
|
||||
SimpleNamespace(
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
tool_calls=tool_calls or None,
|
||||
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=reasoning_details or None,
|
||||
),
|
||||
finish_reason,
|
||||
)
|
||||
|
||||
|
||||
def normalize_anthropic_response_v2(
|
||||
response,
|
||||
strip_tool_prefix: bool = False,
|
||||
) -> "NormalizedResponse":
|
||||
"""Normalize Anthropic response to NormalizedResponse.
|
||||
|
||||
Wraps the existing normalize_anthropic_response() and maps its output
|
||||
to the shared transport types. This allows incremental migration —
|
||||
one call site at a time — without changing the original function.
|
||||
"""
|
||||
from agent.transports.types import NormalizedResponse, build_tool_call
|
||||
|
||||
assistant_msg, finish_reason = normalize_anthropic_response(response, strip_tool_prefix)
|
||||
|
||||
tool_calls = None
|
||||
if assistant_msg.tool_calls:
|
||||
tool_calls = [
|
||||
build_tool_call(
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
arguments=tc.function.arguments,
|
||||
)
|
||||
for tc in assistant_msg.tool_calls
|
||||
]
|
||||
|
||||
provider_data = {}
|
||||
if getattr(assistant_msg, "reasoning_details", None):
|
||||
provider_data["reasoning_details"] = assistant_msg.reasoning_details
|
||||
|
||||
return NormalizedResponse(
|
||||
content=assistant_msg.content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
reasoning=getattr(assistant_msg, "reasoning", None),
|
||||
usage=None, # Anthropic usage is on the raw response, not the normaliser
|
||||
provider_data=provider_data or None,
|
||||
)
|
||||
|
||||
@@ -41,10 +41,13 @@ import threading
|
||||
import time
|
||||
from pathlib import Path # noqa: F401 — used by test mocks
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
@@ -573,7 +576,8 @@ class _AnthropicCompletionsAdapter:
|
||||
self._is_oauth = is_oauth
|
||||
|
||||
def create(self, **kwargs) -> Any:
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
from agent.transports import get_transport
|
||||
|
||||
messages = kwargs.get("messages", [])
|
||||
model = kwargs.get("model", self._model)
|
||||
@@ -610,7 +614,19 @@ class _AnthropicCompletionsAdapter:
|
||||
anthropic_kwargs["temperature"] = temperature
|
||||
|
||||
response = self._client.messages.create(**anthropic_kwargs)
|
||||
assistant_message, finish_reason = normalize_anthropic_response(response)
|
||||
_transport = get_transport("anthropic_messages")
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_oauth
|
||||
)
|
||||
|
||||
# ToolCall already duck-types as OpenAI shape (.type, .function.name,
|
||||
# .function.arguments) via properties, so no wrapping needed.
|
||||
assistant_message = SimpleNamespace(
|
||||
content=_nr.content,
|
||||
tool_calls=_nr.tool_calls,
|
||||
reasoning=_nr.reasoning,
|
||||
)
|
||||
finish_reason = _nr.finish_reason
|
||||
|
||||
usage = None
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
@@ -797,7 +813,11 @@ def _read_codex_access_token() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
# TODO(refactor): This function has messy types and duplicated logic (pool vs direct creds).
|
||||
# Ideal fix: (1) define an AuxiliaryClient Protocol both OpenAI/GeminiNativeClient satisfy,
|
||||
# (2) return a NamedTuple or dataclass instead of raw tuple, (3) extract the repeated
|
||||
# Gemini/Kimi/Copilot client-building into a helper.
|
||||
def _resolve_api_key_provider() -> Tuple[Optional[Union[OpenAI, "GeminiNativeClient"]], Optional[str]]:
|
||||
"""Try each API-key provider in PROVIDER_REGISTRY order.
|
||||
|
||||
Returns (client, model) for the first provider with usable runtime
|
||||
|
||||
@@ -848,7 +848,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown
|
||||
return self._generate_summary(turns_to_summarize) # retry immediately
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) # retry immediately
|
||||
|
||||
# Transient errors (timeout, rate limit, network) — shorter cooldown
|
||||
_transient_cooldown = 60
|
||||
|
||||
@@ -29,6 +29,7 @@ from hermes_cli.auth import (
|
||||
_save_auth_store,
|
||||
_save_provider_state,
|
||||
read_credential_pool,
|
||||
read_provider_credentials,
|
||||
write_credential_pool,
|
||||
)
|
||||
|
||||
@@ -321,7 +322,7 @@ def get_custom_provider_pool_key(base_url: str) -> Optional[str]:
|
||||
|
||||
def list_custom_pool_providers() -> List[str]:
|
||||
"""Return all 'custom:*' pool keys that have entries in auth.json."""
|
||||
pool_data = read_credential_pool(None)
|
||||
pool_data = read_credential_pool()
|
||||
return sorted(
|
||||
key for key in pool_data
|
||||
if key.startswith(CUSTOM_POOL_PREFIX)
|
||||
@@ -875,6 +876,20 @@ class CredentialPool:
|
||||
self._current_id = None
|
||||
return removed
|
||||
|
||||
def remove_entry(self, entry_id: str) -> Optional[PooledCredential]:
|
||||
for idx, entry in enumerate(self._entries):
|
||||
if entry.id == entry_id:
|
||||
removed = self._entries.pop(idx)
|
||||
self._entries = [
|
||||
replace(e, priority=new_priority)
|
||||
for new_priority, e in enumerate(self._entries)
|
||||
]
|
||||
self._persist()
|
||||
if self._current_id == removed.id:
|
||||
self._current_id = None
|
||||
return removed
|
||||
return None
|
||||
|
||||
def resolve_target(self, target: Any) -> Tuple[Optional[int], Optional[PooledCredential], Optional[str]]:
|
||||
raw = str(target or "").strip()
|
||||
if not raw:
|
||||
@@ -1325,7 +1340,7 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b
|
||||
|
||||
def load_pool(provider: str) -> CredentialPool:
|
||||
provider = (provider or "").strip().lower()
|
||||
raw_entries = read_credential_pool(provider)
|
||||
raw_entries = read_provider_credentials(provider)
|
||||
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
|
||||
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
|
||||
@@ -729,6 +729,7 @@ class KawaiiSpinner:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
frame = self.spinner_frames[self.frame_idx % len(self.spinner_frames)]
|
||||
assert self.start_time is not None # start() sets it before thread starts
|
||||
elapsed = time.time() - self.start_time
|
||||
if wings:
|
||||
left, right = wings[self.frame_idx % len(wings)]
|
||||
|
||||
@@ -220,12 +220,25 @@ _TRANSPORT_ERROR_TYPES = frozenset({
|
||||
"ConnectionAbortedError", "BrokenPipeError",
|
||||
"TimeoutError", "ReadError",
|
||||
"ServerDisconnectedError",
|
||||
# SSL/TLS transport errors — transient mid-stream handshake/record
|
||||
# failures that should retry rather than surface as a stalled session.
|
||||
# ssl.SSLError subclasses OSError (caught by isinstance) but we list
|
||||
# the type names here so provider-wrapped SSL errors (e.g. when the
|
||||
# SDK re-raises without preserving the exception chain) still classify
|
||||
# as transport rather than falling through to the unknown bucket.
|
||||
"SSLError", "SSLZeroReturnError", "SSLWantReadError",
|
||||
"SSLWantWriteError", "SSLEOFError", "SSLSyscallError",
|
||||
# OpenAI SDK errors (not subclasses of Python builtins)
|
||||
"APIConnectionError",
|
||||
"APITimeoutError",
|
||||
})
|
||||
|
||||
# Server disconnect patterns (no status code, but transport-level)
|
||||
# Server disconnect patterns (no status code, but transport-level).
|
||||
# These are the "ambiguous" patterns — a plain connection close could be
|
||||
# transient transport hiccup OR server-side context overflow rejection
|
||||
# (common when the API gateway disconnects instead of returning an HTTP
|
||||
# error for oversized requests). A large session + one of these patterns
|
||||
# triggers the context-overflow-with-compression recovery path.
|
||||
_SERVER_DISCONNECT_PATTERNS = [
|
||||
"server disconnected",
|
||||
"peer closed connection",
|
||||
@@ -236,6 +249,40 @@ _SERVER_DISCONNECT_PATTERNS = [
|
||||
"incomplete chunked read",
|
||||
]
|
||||
|
||||
# SSL/TLS transient failure patterns — intentionally distinct from
|
||||
# _SERVER_DISCONNECT_PATTERNS above.
|
||||
#
|
||||
# An SSL alert mid-stream is almost always a transport-layer hiccup
|
||||
# (flaky network, mid-session TLS renegotiation failure, load balancer
|
||||
# dropping the connection) — NOT a server-side context overflow signal.
|
||||
# So we want the retry path but NOT the compression path; lumping these
|
||||
# into _SERVER_DISCONNECT_PATTERNS would trigger unnecessary (and
|
||||
# expensive) context compression on any large-session SSL hiccup.
|
||||
#
|
||||
# The OpenSSL library constructs error codes by prepending a format string
|
||||
# to the uppercased alert reason; OpenSSL 3.x changed the separator
|
||||
# (e.g. `SSLV3_ALERT_BAD_RECORD_MAC` → `SSL/TLS_ALERT_BAD_RECORD_MAC`),
|
||||
# which silently stopped matching anything explicit. Matching on the
|
||||
# stable substrings (`bad record mac`, `ssl alert`, `tls alert`, etc.)
|
||||
# survives future OpenSSL format churn without code changes.
|
||||
_SSL_TRANSIENT_PATTERNS = [
|
||||
# Space-separated (human-readable form, Python ssl module, most SDKs)
|
||||
"bad record mac",
|
||||
"ssl alert",
|
||||
"tls alert",
|
||||
"ssl handshake failure",
|
||||
"tlsv1 alert",
|
||||
"sslv3 alert",
|
||||
# Underscore-separated (OpenSSL error code tokens, e.g.
|
||||
# `ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC`, `SSLV3_ALERT_BAD_RECORD_MAC`)
|
||||
"bad_record_mac",
|
||||
"ssl_alert",
|
||||
"tls_alert",
|
||||
"tls_alert_internal_error",
|
||||
# Python ssl module prefix, e.g. "[SSL: BAD_RECORD_MAC]"
|
||||
"[ssl:",
|
||||
]
|
||||
|
||||
|
||||
# ── Classification pipeline ─────────────────────────────────────────────
|
||||
|
||||
@@ -255,9 +302,10 @@ def classify_api_error(
|
||||
2. HTTP status code + message-aware refinement
|
||||
3. Error code classification (from body)
|
||||
4. Message pattern matching (billing vs rate_limit vs context vs auth)
|
||||
5. Transport error heuristics
|
||||
5. SSL/TLS transient alert patterns → retry as timeout
|
||||
6. Server disconnect + large session → context overflow
|
||||
7. Fallback: unknown (retryable with backoff)
|
||||
7. Transport error heuristics
|
||||
8. Fallback: unknown (retryable with backoff)
|
||||
|
||||
Args:
|
||||
error: The exception from the API call.
|
||||
@@ -388,7 +436,18 @@ def classify_api_error(
|
||||
if classified is not None:
|
||||
return classified
|
||||
|
||||
# ── 5. Server disconnect + large session → context overflow ─────
|
||||
# ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
|
||||
# SSL alerts mid-stream are transport hiccups, not server-side context
|
||||
# overflow signals. Classify before the disconnect check so a large
|
||||
# session doesn't incorrectly trigger context compression when the real
|
||||
# cause is a flaky TLS handshake. Also matches when the error is
|
||||
# wrapped in a generic exception whose message string carries the SSL
|
||||
# alert text but the type isn't ssl.SSLError (happens with some SDKs
|
||||
# that re-raise without chaining).
|
||||
if any(p in error_msg for p in _SSL_TRANSIENT_PATTERNS):
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 6. Server disconnect + large session → context overflow ─────
|
||||
# Must come BEFORE generic transport error catch — a disconnect on
|
||||
# a large session is more likely context overflow than a transient
|
||||
# transport hiccup. Without this ordering, RemoteProtocolError
|
||||
@@ -405,12 +464,12 @@ def classify_api_error(
|
||||
)
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 6. Transport / timeout heuristics ───────────────────────────
|
||||
# ── 7. Transport / timeout heuristics ───────────────────────────
|
||||
|
||||
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 7. Fallback: unknown ────────────────────────────────────────
|
||||
# ── 8. Fallback: unknown ────────────────────────────────────────
|
||||
|
||||
return _result(FailoverReason.unknown, retryable=True)
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@ _CONTEXT_LENGTH_KEYS = (
|
||||
"max_seq_len",
|
||||
"n_ctx_train",
|
||||
"n_ctx",
|
||||
"ctx_size",
|
||||
)
|
||||
|
||||
_MAX_COMPLETION_KEYS = (
|
||||
@@ -246,6 +247,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"chatgpt.com": "openai",
|
||||
"api.anthropic.com": "anthropic",
|
||||
"api.z.ai": "zai",
|
||||
"open.bigmodel.cn": "zai",
|
||||
"api.moonshot.ai": "kimi-coding",
|
||||
"api.moonshot.cn": "kimi-coding-cn",
|
||||
"api.kimi.com": "kimi-coding",
|
||||
|
||||
@@ -418,6 +418,9 @@ def list_provider_models(provider: str) -> List[str]:
|
||||
|
||||
Returns an empty list if the provider is unknown or has no data.
|
||||
"""
|
||||
from hermes_cli.models import normalize_provider
|
||||
provider = normalize_provider(provider) or provider
|
||||
|
||||
models = _get_provider_models(provider)
|
||||
if models is None:
|
||||
return []
|
||||
|
||||
@@ -370,6 +370,32 @@ PLATFORM_HINTS = {
|
||||
"MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, "
|
||||
".heic) appear as photos and other files arrive as attachments."
|
||||
),
|
||||
"mattermost": (
|
||||
"You are in a Mattermost workspace communicating with your user. "
|
||||
"Mattermost renders standard Markdown — headings, bold, italic, code "
|
||||
"blocks, and tables all work. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are uploaded as photo "
|
||||
"attachments, audio and video as file attachments. "
|
||||
"Image URLs in markdown format  are rendered as inline previews automatically."
|
||||
),
|
||||
"matrix": (
|
||||
"You are in a Matrix room communicating with your user. "
|
||||
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
|
||||
"the adapter converts your Markdown to HTML for rich display. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
|
||||
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
|
||||
"and other files as downloadable attachments."
|
||||
),
|
||||
"feishu": (
|
||||
"You are in a Feishu (Lark) workspace communicating with your user. "
|
||||
"Feishu renders Markdown in messages — bold, italic, code blocks, and "
|
||||
"links are supported. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are uploaded and displayed "
|
||||
"inline, audio files as voice messages, and other files as attachments."
|
||||
),
|
||||
"weixin": (
|
||||
"You are on Weixin/WeChat. Markdown formatting is supported, so you may use it when "
|
||||
"it improves readability, but keep the message compact and chat-friendly. You can send media files natively: "
|
||||
|
||||
@@ -435,7 +435,7 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
Excludes ``.git``, ``.github``, ``.hub`` directories.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir):
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
|
||||
if filename in files:
|
||||
matches.append(Path(root) / filename)
|
||||
@@ -455,7 +455,8 @@ def parse_qualified_name(name: str) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
if ":" not in name:
|
||||
return None, name
|
||||
return tuple(name.split(":", 1)) # type: ignore[return-value]
|
||||
ns, bare = name.split(":", 1)
|
||||
return ns, bare
|
||||
|
||||
|
||||
def is_valid_namespace(candidate: Optional[str]) -> bool:
|
||||
|
||||
@@ -38,7 +38,7 @@ def generate_title(user_message: str, assistant_response: str, timeout: float =
|
||||
response = call_llm(
|
||||
task="title_generation",
|
||||
messages=messages,
|
||||
max_tokens=30,
|
||||
max_tokens=500,
|
||||
temperature=0.3,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -78,13 +78,55 @@ class AnthropicTransport(ProviderTransport):
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
"""Normalize Anthropic response to NormalizedResponse.
|
||||
|
||||
kwargs:
|
||||
strip_tool_prefix: bool — strip 'mcp_mcp_' prefixes from tool names.
|
||||
Parses content blocks (text, thinking, tool_use), maps stop_reason
|
||||
to OpenAI finish_reason, and collects reasoning_details in provider_data.
|
||||
"""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response_v2
|
||||
import json
|
||||
from agent.anthropic_adapter import _to_plain_data
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
return normalize_anthropic_response_v2(response, strip_tool_prefix=strip_tool_prefix)
|
||||
_MCP_PREFIX = "mcp_"
|
||||
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
reasoning_details = []
|
||||
tool_calls = []
|
||||
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
elif block.type == "thinking":
|
||||
reasoning_parts.append(block.thinking)
|
||||
block_dict = _to_plain_data(block)
|
||||
if isinstance(block_dict, dict):
|
||||
reasoning_details.append(block_dict)
|
||||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||
name = name[len(_MCP_PREFIX):]
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=block.id,
|
||||
name=name,
|
||||
arguments=json.dumps(block.input),
|
||||
)
|
||||
)
|
||||
|
||||
finish_reason = self._STOP_REASON_MAP.get(response.stop_reason, "stop")
|
||||
|
||||
provider_data = {}
|
||||
if reasoning_details:
|
||||
provider_data["reasoning_details"] = reasoning_details
|
||||
|
||||
return NormalizedResponse(
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
tool_calls=tool_calls or None,
|
||||
finish_reason=finish_reason,
|
||||
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
||||
usage=None,
|
||||
provider_data=provider_data or None,
|
||||
)
|
||||
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
@@ -37,6 +37,30 @@ class ToolCall:
|
||||
arguments: str # JSON string
|
||||
provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
|
||||
|
||||
# ── Backward compatibility ──────────────────────────────────
|
||||
# The agent loop reads tc.function.name / tc.function.arguments
|
||||
# throughout run_agent.py (45+ sites). These properties let
|
||||
# NormalizedResponse pass through without the _nr_to_assistant_message
|
||||
# shim, while keeping ToolCall's canonical fields flat.
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "function"
|
||||
|
||||
@property
|
||||
def function(self) -> "ToolCall":
|
||||
"""Return self so tc.function.name / tc.function.arguments work."""
|
||||
return self
|
||||
|
||||
@property
|
||||
def call_id(self) -> Optional[str]:
|
||||
"""Codex call_id from provider_data, accessed via getattr by _build_assistant_message."""
|
||||
return (self.provider_data or {}).get("call_id")
|
||||
|
||||
@property
|
||||
def response_item_id(self) -> Optional[str]:
|
||||
"""Codex response_item_id from provider_data."""
|
||||
return (self.provider_data or {}).get("response_item_id")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Usage:
|
||||
@@ -70,6 +94,24 @@ class NormalizedResponse:
|
||||
usage: Optional[Usage] = None
|
||||
provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
|
||||
|
||||
# ── Backward compatibility ──────────────────────────────────
|
||||
# The shim _nr_to_assistant_message() mapped these from provider_data.
|
||||
# These properties let NormalizedResponse pass through directly.
|
||||
@property
|
||||
def reasoning_content(self) -> Optional[str]:
|
||||
pd = self.provider_data or {}
|
||||
return pd.get("reasoning_content")
|
||||
|
||||
@property
|
||||
def reasoning_details(self):
|
||||
pd = self.provider_data or {}
|
||||
return pd.get("reasoning_details")
|
||||
|
||||
@property
|
||||
def codex_reasoning_items(self):
|
||||
pd = self.provider_data or {}
|
||||
return pd.get("codex_reasoning_items")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory helpers
|
||||
|
||||
@@ -533,10 +533,22 @@ def normalize_usage(
|
||||
prompt_total = _to_int(getattr(response_usage, "prompt_tokens", 0))
|
||||
output_tokens = _to_int(getattr(response_usage, "completion_tokens", 0))
|
||||
details = getattr(response_usage, "prompt_tokens_details", None)
|
||||
# Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style
|
||||
# top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel
|
||||
# AI Gateway, Cline) expose when routing Claude models — without this
|
||||
# fallback, cache writes are undercounted as 0 and cache reads can be
|
||||
# missed when the proxy only surfaces them at the top level.
|
||||
# Port of cline/cline#10266.
|
||||
cache_read_tokens = _to_int(getattr(details, "cached_tokens", 0) if details else 0)
|
||||
if not cache_read_tokens:
|
||||
cache_read_tokens = _to_int(getattr(response_usage, "cache_read_input_tokens", 0))
|
||||
cache_write_tokens = _to_int(
|
||||
getattr(details, "cache_write_tokens", 0) if details else 0
|
||||
)
|
||||
if not cache_write_tokens:
|
||||
cache_write_tokens = _to_int(
|
||||
getattr(response_usage, "cache_creation_input_tokens", 0)
|
||||
)
|
||||
input_tokens = max(0, prompt_total - cache_read_tokens - cache_write_tokens)
|
||||
|
||||
reasoning_tokens = 0
|
||||
|
||||
@@ -776,6 +776,7 @@ delegation:
|
||||
# max_concurrent_children: 3 # Max parallel child agents (default: 3)
|
||||
# max_spawn_depth: 1 # Tree depth cap (1-3, default: 1 = flat). Raise to 2 or 3 to allow orchestrator children to spawn their own workers.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
# inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: true). Set false for strict intersection.
|
||||
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
|
||||
# provider: "openrouter" # Override provider for subagents (empty = inherit parent)
|
||||
# # Resolves full credentials (base_url, api_key) automatically.
|
||||
|
||||
@@ -30,7 +30,7 @@ from urllib.parse import unquote, urlparse
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import List, Dict, Any, Optional, TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,6 +84,34 @@ _project_env = Path(__file__).parent / '.env'
|
||||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
||||
|
||||
|
||||
class _ModelPickerState(TypedDict, total=False):
|
||||
stage: str
|
||||
providers: List[Dict[str, Any]]
|
||||
selected: int
|
||||
current_model: str
|
||||
current_provider: str
|
||||
user_provs: Optional[Dict[str, Any]]
|
||||
custom_provs: Optional[Dict[str, Any]]
|
||||
provider_data: Dict[str, Any]
|
||||
model_list: List[str]
|
||||
|
||||
|
||||
class _ApprovalState(TypedDict, total=False):
|
||||
command: str
|
||||
description: str
|
||||
choices: List[str]
|
||||
selected: int
|
||||
response_queue: "queue.Queue[str]"
|
||||
show_full: bool
|
||||
|
||||
|
||||
class _ClarifyState(TypedDict, total=False):
|
||||
question: str
|
||||
choices: List[str]
|
||||
selected: int
|
||||
response_queue: "queue.Queue[str]"
|
||||
|
||||
|
||||
_REASONING_TAGS = (
|
||||
"REASONING_SCRATCHPAD",
|
||||
"think",
|
||||
@@ -108,6 +136,11 @@ def _strip_reasoning_tags(text: str) -> str:
|
||||
``<thought>`` (Gemma 4). Must stay in sync with
|
||||
``run_agent.py::_strip_think_blocks`` and the stream consumer's
|
||||
``_OPEN_THINK_TAGS`` / ``_CLOSE_THINK_TAGS`` tuples.
|
||||
|
||||
Also strips tool-call XML blocks some open models leak into visible
|
||||
content (``<tool_call>``, ``<function_calls>``, Gemma-style
|
||||
``<function name="…">…</function>``). Ported from
|
||||
openclaw/openclaw#67318.
|
||||
"""
|
||||
cleaned = text
|
||||
for tag in _REASONING_TAGS:
|
||||
@@ -132,6 +165,31 @@ def _strip_reasoning_tags(text: str) -> str:
|
||||
cleaned,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Tool-call XML blocks (openclaw/openclaw#67318).
|
||||
for tc_tag in ("tool_call", "tool_calls", "tool_result",
|
||||
"function_call", "function_calls"):
|
||||
cleaned = re.sub(
|
||||
rf"<{tc_tag}\b[^>]*>.*?</{tc_tag}>\s*",
|
||||
"",
|
||||
cleaned,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
# <function name="..."> — boundary + attribute gated to avoid prose FPs.
|
||||
cleaned = re.sub(
|
||||
r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*'
|
||||
r'<function\b[^>]*\bname\s*=[^>]*>'
|
||||
r'(?:(?:(?!</function>).)*)</function>\s*',
|
||||
'',
|
||||
cleaned,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
# Stray tool-call close tags.
|
||||
cleaned = re.sub(
|
||||
r'</(?:tool_call|tool_calls|tool_result|function_call|function_calls|function)>\s*',
|
||||
'',
|
||||
cleaned,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
@@ -275,13 +333,23 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
|
||||
Environment variables take precedence over config file values.
|
||||
Returns default values if no config file exists.
|
||||
|
||||
If HERMES_IGNORE_USER_CONFIG=1 is set (via ``hermes chat --ignore-user-config``),
|
||||
the user config at ``~/.hermes/config.yaml`` is skipped entirely and only the
|
||||
built-in defaults plus the project-level ``cli-config.yaml`` (if any) are used.
|
||||
Credentials in ``.env`` are still loaded — this flag only suppresses
|
||||
behavioral/config settings.
|
||||
"""
|
||||
# Check user config first ({HERMES_HOME}/config.yaml)
|
||||
user_config_path = _hermes_home / 'config.yaml'
|
||||
project_config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
|
||||
# --ignore-user-config: force-skip the user config.yaml (still honor project
|
||||
# config as a fallback so defaults stay sensible).
|
||||
ignore_user_config = os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
|
||||
|
||||
# Use user config if it exists, otherwise project config
|
||||
if user_config_path.exists():
|
||||
if user_config_path.exists() and not ignore_user_config:
|
||||
config_path = user_config_path
|
||||
else:
|
||||
config_path = project_config_path
|
||||
@@ -1688,7 +1756,7 @@ def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) ->
|
||||
return parsed
|
||||
|
||||
|
||||
def save_config_value(key_path: str, value: any) -> bool:
|
||||
def save_config_value(key_path: str, value: Any) -> bool:
|
||||
"""
|
||||
Save a value to the active config file at the specified key path.
|
||||
|
||||
@@ -1772,6 +1840,7 @@ class HermesCLI:
|
||||
resume: str = None,
|
||||
checkpoints: bool = False,
|
||||
pass_session_id: bool = False,
|
||||
ignore_rules: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the Hermes CLI.
|
||||
@@ -1925,6 +1994,11 @@ class HermesCLI:
|
||||
self.checkpoints_enabled = checkpoints or cp_cfg.get("enabled", False)
|
||||
self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 50)
|
||||
self.pass_session_id = pass_session_id
|
||||
# --ignore-rules: honor either the constructor flag or the env var set
|
||||
# by `hermes chat --ignore-rules` in hermes_cli/main.py. When true we
|
||||
# pass skip_context_files=True and skip_memory=True to AIAgent so
|
||||
# AGENTS.md/SOUL.md/.cursorrules and persistent memory are not loaded.
|
||||
self.ignore_rules = ignore_rules or os.environ.get("HERMES_IGNORE_RULES") == "1"
|
||||
|
||||
# Ephemeral system prompt: env var takes precedence, then config
|
||||
self.system_prompt = (
|
||||
@@ -2019,16 +2093,16 @@ class HermesCLI:
|
||||
self._interrupt_queue = queue.Queue()
|
||||
self._should_exit = False
|
||||
self._last_ctrl_c_time = 0
|
||||
self._clarify_state = None
|
||||
self._clarify_state: Optional[_ClarifyState] = None
|
||||
self._clarify_freetext = False
|
||||
self._clarify_deadline = 0
|
||||
self._sudo_state = None
|
||||
self._sudo_deadline = 0
|
||||
self._modal_input_snapshot = None
|
||||
self._approval_state = None
|
||||
self._approval_state: Optional[_ApprovalState] = None
|
||||
self._approval_deadline = 0
|
||||
self._approval_lock = threading.Lock()
|
||||
self._model_picker_state = None
|
||||
self._model_picker_state: Optional[_ModelPickerState] = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
@@ -3282,6 +3356,8 @@ class HermesCLI:
|
||||
checkpoints_enabled=self.checkpoints_enabled,
|
||||
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
|
||||
pass_session_id=self.pass_session_id,
|
||||
skip_context_files=self.ignore_rules,
|
||||
skip_memory=self.ignore_rules,
|
||||
tool_progress_callback=self._on_tool_progress,
|
||||
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
|
||||
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
|
||||
@@ -7108,7 +7184,7 @@ class HermesCLI:
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
else:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
for quiet_logger in ('tools', 'run_agent', 'trajectory_compressor', 'cron', 'hermes_cli'):
|
||||
for quiet_logger in ('tools', 'run_agent', 'scripts.trajectory_compressor', 'cron', 'hermes_cli'):
|
||||
logging.getLogger(quiet_logger).setLevel(logging.ERROR)
|
||||
|
||||
def _show_insights(self, command: str = "/insights"):
|
||||
@@ -10786,6 +10862,8 @@ def main(
|
||||
w: bool = False,
|
||||
checkpoints: bool = False,
|
||||
pass_session_id: bool = False,
|
||||
ignore_user_config: bool = False,
|
||||
ignore_rules: bool = False,
|
||||
):
|
||||
"""
|
||||
Hermes Agent CLI - Interactive AI Assistant
|
||||
@@ -10895,6 +10973,7 @@ def main(
|
||||
resume=resume,
|
||||
checkpoints=checkpoints,
|
||||
pass_session_id=pass_session_id,
|
||||
ignore_rules=ignore_rules,
|
||||
)
|
||||
|
||||
if parsed_skills:
|
||||
|
||||
+9
-2
@@ -439,8 +439,9 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
if result and result.get("error"):
|
||||
msg = f"delivery error: {result['error']}"
|
||||
error = result.get("error") if result else None
|
||||
if error:
|
||||
msg = f"delivery error: {error}"
|
||||
logger.error("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
@@ -972,6 +973,12 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
f"— last activity: {_last_desc}"
|
||||
)
|
||||
|
||||
# Guard against non-dict returns from run_conversation under error conditions
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(
|
||||
f"agent.run_conversation returned {type(result).__name__} instead of dict: {result!r}"
|
||||
)
|
||||
|
||||
final_response = result.get("final_response", "") or ""
|
||||
# Strip leaked placeholder text that upstream may inject on empty completions.
|
||||
if final_response.strip() == "(No response generated)":
|
||||
|
||||
@@ -29,7 +29,7 @@ echo "📝 Logging to: $LOG_FILE"
|
||||
# Point to the example dataset in this directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
python batch_runner.py \
|
||||
python scripts/batch_runner.py \
|
||||
--dataset_file="$SCRIPT_DIR/example_browser_tasks.jsonl" \
|
||||
--batch_size=5 \
|
||||
--run_name="browser_tasks_example" \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Generates tool-calling trajectories for multi-step web research tasks.
|
||||
#
|
||||
# Usage:
|
||||
# python batch_runner.py \
|
||||
# python scripts/batch_runner.py \
|
||||
# --config datagen-config-examples/web_research.yaml \
|
||||
# --run_name web_research_v1
|
||||
|
||||
|
||||
@@ -58,6 +58,13 @@ if [ ! -f "$HERMES_HOME/config.yaml" ]; then
|
||||
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# Ensure the main config file remains accessible to the hermes runtime user
|
||||
# even if it was edited on the host after initial ownership setup.
|
||||
if [ -f "$HERMES_HOME/config.yaml" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/config.yaml"
|
||||
chmod 640 "$HERMES_HOME/config.yaml"
|
||||
fi
|
||||
|
||||
# SOUL.md
|
||||
if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
|
||||
cp "$INSTALL_DIR/docker/SOUL.md" "$HERMES_HOME/SOUL.md"
|
||||
@@ -68,4 +75,19 @@ if [ -d "$INSTALL_DIR/skills" ]; then
|
||||
python3 "$INSTALL_DIR/tools/skills_sync.py"
|
||||
fi
|
||||
|
||||
# Final exec: two supported invocation patterns.
|
||||
#
|
||||
# docker run <image> -> exec `hermes` with no args (legacy default)
|
||||
# docker run <image> chat -q "..." -> exec `hermes chat -q "..."` (legacy wrap)
|
||||
# docker run <image> sleep infinity -> exec `sleep infinity` directly
|
||||
# docker run <image> bash -> exec `bash` directly
|
||||
#
|
||||
# If the first positional arg resolves to an executable on PATH, we assume the
|
||||
# caller wants to run it directly (needed by the launcher which runs long-lived
|
||||
# `sleep infinity` sandbox containers — see tools/environments/docker.py).
|
||||
# Otherwise we treat the args as a hermes subcommand and wrap with `hermes`,
|
||||
# preserving the documented `docker run <image> <subcommand>` behavior.
|
||||
if [ $# -gt 0 ] && command -v "$1" >/dev/null 2>&1; then
|
||||
exec "$@"
|
||||
fi
|
||||
exec hermes "$@"
|
||||
|
||||
@@ -18,7 +18,10 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tools.budget_config import BudgetConfig
|
||||
|
||||
from model_tools import handle_function_call
|
||||
from tools.terminal_tool import get_active_env
|
||||
|
||||
@@ -32,14 +32,7 @@ import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
AIOHTTP_AVAILABLE = False
|
||||
web = None # type: ignore[assignment]
|
||||
|
||||
from aiohttp import web
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
@@ -270,12 +263,6 @@ def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Respons
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
def check_api_server_requirements() -> bool:
|
||||
"""Check if API server dependencies are available."""
|
||||
return AIOHTTP_AVAILABLE
|
||||
|
||||
|
||||
class ResponseStore:
|
||||
"""
|
||||
SQLite-backed LRU store for Responses API state.
|
||||
@@ -391,30 +378,26 @@ _CORS_HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
if AIOHTTP_AVAILABLE:
|
||||
@web.middleware
|
||||
async def cors_middleware(request, handler):
|
||||
"""Add CORS headers for explicitly allowed origins; handle OPTIONS preflight."""
|
||||
adapter = request.app.get("api_server_adapter")
|
||||
origin = request.headers.get("Origin", "")
|
||||
cors_headers = None
|
||||
if adapter is not None:
|
||||
if not adapter._origin_allowed(origin):
|
||||
return web.Response(status=403)
|
||||
cors_headers = adapter._cors_headers_for_origin(origin)
|
||||
@web.middleware
|
||||
async def cors_middleware(request, handler):
|
||||
"""Add CORS headers for explicitly allowed origins; handle OPTIONS preflight."""
|
||||
adapter = request.app.get("api_server_adapter")
|
||||
origin = request.headers.get("Origin", "")
|
||||
cors_headers = None
|
||||
if adapter is not None:
|
||||
if not adapter._origin_allowed(origin):
|
||||
return web.Response(status=403)
|
||||
cors_headers = adapter._cors_headers_for_origin(origin)
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
if cors_headers is None:
|
||||
return web.Response(status=403)
|
||||
return web.Response(status=200, headers=cors_headers)
|
||||
|
||||
response = await handler(request)
|
||||
if cors_headers is not None:
|
||||
response.headers.update(cors_headers)
|
||||
return response
|
||||
else:
|
||||
cors_middleware = None # type: ignore[assignment]
|
||||
if request.method == "OPTIONS":
|
||||
if cors_headers is None:
|
||||
return web.Response(status=403)
|
||||
return web.Response(status=200, headers=cors_headers)
|
||||
|
||||
response = await handler(request)
|
||||
if cors_headers is not None:
|
||||
response.headers.update(cors_headers)
|
||||
return response
|
||||
|
||||
def _openai_error(message: str, err_type: str = "invalid_request_error", param: str = None, code: str = None) -> Dict[str, Any]:
|
||||
"""OpenAI-style error envelope."""
|
||||
@@ -428,21 +411,18 @@ def _openai_error(message: str, err_type: str = "invalid_request_error", param:
|
||||
}
|
||||
|
||||
|
||||
if AIOHTTP_AVAILABLE:
|
||||
@web.middleware
|
||||
async def body_limit_middleware(request, handler):
|
||||
"""Reject overly large request bodies early based on Content-Length."""
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
cl = request.headers.get("Content-Length")
|
||||
if cl is not None:
|
||||
try:
|
||||
if int(cl) > MAX_REQUEST_BYTES:
|
||||
return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413)
|
||||
except ValueError:
|
||||
return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400)
|
||||
return await handler(request)
|
||||
else:
|
||||
body_limit_middleware = None # type: ignore[assignment]
|
||||
@web.middleware
|
||||
async def body_limit_middleware(request, handler):
|
||||
"""Reject overly large request bodies early based on Content-Length."""
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
cl = request.headers.get("Content-Length")
|
||||
if cl is not None:
|
||||
try:
|
||||
if int(cl) > MAX_REQUEST_BYTES:
|
||||
return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413)
|
||||
except ValueError:
|
||||
return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400)
|
||||
return await handler(request)
|
||||
|
||||
_SECURITY_HEADERS = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
@@ -450,16 +430,13 @@ _SECURITY_HEADERS = {
|
||||
}
|
||||
|
||||
|
||||
if AIOHTTP_AVAILABLE:
|
||||
@web.middleware
|
||||
async def security_headers_middleware(request, handler):
|
||||
"""Add security headers to all responses (including errors)."""
|
||||
response = await handler(request)
|
||||
for k, v in _SECURITY_HEADERS.items():
|
||||
response.headers.setdefault(k, v)
|
||||
return response
|
||||
else:
|
||||
security_headers_middleware = None # type: ignore[assignment]
|
||||
@web.middleware
|
||||
async def security_headers_middleware(request, handler):
|
||||
"""Add security headers to all responses (including errors)."""
|
||||
response = await handler(request)
|
||||
for k, v in _SECURITY_HEADERS.items():
|
||||
response.headers.setdefault(k, v)
|
||||
return response
|
||||
|
||||
|
||||
class _IdempotencyCache:
|
||||
@@ -804,7 +781,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
],
|
||||
})
|
||||
|
||||
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_chat_completions(self, request: "web.Request") -> "web.StreamResponse":
|
||||
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
@@ -1588,7 +1565,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
return response
|
||||
|
||||
async def _handle_responses(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_responses(self, request: "web.Request") -> "web.StreamResponse":
|
||||
"""POST /v1/responses — OpenAI Responses API format."""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
@@ -2482,10 +2459,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Start the aiohttp web server."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
logger.warning("[%s] aiohttp not installed", self.name)
|
||||
return False
|
||||
|
||||
try:
|
||||
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
||||
self._app = web.Application(middlewares=mws)
|
||||
|
||||
+26
-22
@@ -187,16 +187,14 @@ def proxy_kwargs_for_bot(proxy_url: str | None) -> dict:
|
||||
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 {}
|
||||
raise ImportError(
|
||||
"aiohttp-socks is required for SOCKS proxy support. "
|
||||
"Install with: pip install hermes-agent[messaging]"
|
||||
) from None
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}
|
||||
return {"proxy": proxy_url}
|
||||
|
||||
|
||||
@@ -220,16 +218,14 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
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 {}, {}
|
||||
raise ImportError(
|
||||
"aiohttp-socks is required for SOCKS proxy support. "
|
||||
"Install with: pip install hermes-agent[messaging]"
|
||||
) from None
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
return {}, {"proxy": proxy_url}
|
||||
|
||||
|
||||
@@ -428,6 +424,7 @@ async def cache_image_from_url(url: str, ext: str = ".jpg", retries: int = 2) ->
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
raise AssertionError("unreachable: retry loop exhausted")
|
||||
|
||||
|
||||
def cleanup_image_cache(max_age_hours: int = 24) -> int:
|
||||
@@ -542,6 +539,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) ->
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
raise AssertionError("unreachable: retry loop exhausted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -752,7 +750,10 @@ class MessageEvent:
|
||||
if not self.is_command():
|
||||
return self.text
|
||||
parts = self.text.split(maxsplit=1)
|
||||
return parts[1] if len(parts) > 1 else ""
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
# iOS auto-corrects -- to — (em dash) and - to – (en dash)
|
||||
args = args.replace("\u2014\u2014", "--").replace("\u2014", "--").replace("\u2013", "-")
|
||||
return args
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1343,7 +1344,7 @@ class BasePlatformAdapter(ABC):
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
media_pattern = re.compile(
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|pdf)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
|
||||
)
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
@@ -1828,8 +1829,11 @@ class BasePlatformAdapter(ABC):
|
||||
try:
|
||||
await self._run_processing_hook("on_processing_start", event)
|
||||
|
||||
# Call the handler (this can take a while with tool calls)
|
||||
response = await self._message_handler(event)
|
||||
handler = self._message_handler
|
||||
if handler is None:
|
||||
return
|
||||
|
||||
response = await handler(event)
|
||||
|
||||
# Send response if any. A None/empty response is normal when
|
||||
# streaming already delivered the text (already_sent=True) or
|
||||
|
||||
@@ -14,7 +14,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -377,7 +377,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
payload = {
|
||||
"addresses": [address],
|
||||
"message": message,
|
||||
"tempGuid": f"temp-{datetime.utcnow().timestamp()}",
|
||||
"tempGuid": f"temp-{datetime.now(timezone.utc).timestamp()}",
|
||||
}
|
||||
try:
|
||||
res = await self._api_post("/api/v1/chat/new", payload)
|
||||
@@ -417,7 +417,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
)
|
||||
payload: Dict[str, Any] = {
|
||||
"chatGuid": guid,
|
||||
"tempGuid": f"temp-{datetime.utcnow().timestamp()}",
|
||||
"tempGuid": f"temp-{datetime.now(timezone.utc).timestamp()}",
|
||||
"message": chunk,
|
||||
}
|
||||
if reply_to and self._private_api_enabled and self._helper_connected:
|
||||
|
||||
@@ -527,6 +527,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# Reply threading mode: "off" (no replies), "first" (reply on first
|
||||
# chunk only, default), "all" (reply-reference on every chunk).
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._slash_commands: bool = self.config.extra.get("slash_commands", True)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Discord and start receiving events."""
|
||||
@@ -744,7 +745,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
# Register slash commands
|
||||
self._register_slash_commands()
|
||||
if self._slash_commands:
|
||||
self._register_slash_commands()
|
||||
|
||||
# Start the bot in background
|
||||
self._bot_task = asyncio.create_task(self._client.start(self.config.token))
|
||||
@@ -1194,9 +1196,16 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
import base64
|
||||
|
||||
duration_secs = 5.0
|
||||
try:
|
||||
from mutagen.oggopus import OggOpus
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mutagen is required for Discord voice messages. "
|
||||
"Install with: pip install hermes-agent[messaging]"
|
||||
) from None
|
||||
|
||||
duration_secs = 5.0
|
||||
try:
|
||||
info = OggOpus(audio_path)
|
||||
duration_secs = info.info.length
|
||||
except Exception:
|
||||
@@ -1889,7 +1898,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# Fetch full member list (requires members intent)
|
||||
try:
|
||||
members = guild.members
|
||||
if len(members) < guild.member_count:
|
||||
if guild.member_count is not None and len(members) < guild.member_count:
|
||||
members = [m async for m in guild.fetch_members(limit=None)]
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch members for guild %s: %s", guild.name, e)
|
||||
@@ -2502,7 +2511,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if isinstance(skills, str):
|
||||
return [skills]
|
||||
if isinstance(skills, list) and skills:
|
||||
return list(dict.fromkeys(skills)) # dedup, preserve order
|
||||
return list(dict.fromkeys(skills)) # ty: ignore[invalid-return-type] # dedup, preserve order
|
||||
return None
|
||||
|
||||
def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None:
|
||||
@@ -3038,7 +3047,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
# Skip the mention check if the message is in a thread where
|
||||
# the bot has previously participated (auto-created or replied in).
|
||||
in_bot_thread = is_thread and thread_id in self._threads
|
||||
in_bot_thread = is_thread and thread_id is not None and thread_id in self._threads
|
||||
|
||||
if require_mention and not is_free_channel and not in_bot_thread:
|
||||
if self._client.user not in message.mentions and not mention_prefix:
|
||||
@@ -3631,7 +3640,9 @@ if DISCORD_AVAILABLE:
|
||||
)
|
||||
return
|
||||
|
||||
provider_slug = interaction.data["values"][0]
|
||||
if interaction.data is None:
|
||||
return
|
||||
provider_slug = interaction.data["values"][0] # ty: ignore[invalid-key]
|
||||
self._selected_provider = provider_slug
|
||||
provider = next(
|
||||
(p for p in self.providers if p["slug"] == provider_slug), None
|
||||
@@ -3665,8 +3676,10 @@ if DISCORD_AVAILABLE:
|
||||
)
|
||||
return
|
||||
|
||||
if interaction.data is None:
|
||||
return
|
||||
self.resolved = True
|
||||
model_id = interaction.data["values"][0]
|
||||
model_id = interaction.data["values"][0] # ty: ignore[invalid-key]
|
||||
|
||||
try:
|
||||
result_text = await self.on_model_selected(
|
||||
|
||||
@@ -532,6 +532,7 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image URL as part of an email body."""
|
||||
text = caption or ""
|
||||
@@ -545,6 +546,7 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
caption: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a file as an email attachment."""
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,35 @@ Supports:
|
||||
- Interactive card button-click events routed as synthetic COMMAND events
|
||||
- Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker)
|
||||
- Verification token validation as second auth layer (matches openclaw)
|
||||
|
||||
Feishu identity model
|
||||
---------------------
|
||||
Feishu uses three user-ID tiers (official docs:
|
||||
https://open.feishu.cn/document/home/user-identity-introduction/introduction):
|
||||
|
||||
open_id (ou_xxx) — **App-scoped**. The same person gets a different
|
||||
open_id under each Feishu app. Always available in
|
||||
event payloads without extra permissions.
|
||||
user_id (u_xxx) — **Tenant-scoped**. Stable within a company but
|
||||
requires the ``contact:user.employee_id:readonly``
|
||||
scope. May not be present.
|
||||
union_id (on_xxx) — **Developer-scoped**. Same across all apps owned by
|
||||
one developer/ISV. Best cross-app stable ID.
|
||||
|
||||
For bots specifically:
|
||||
|
||||
app_id — The application's canonical credential identifier.
|
||||
bot open_id — Returned by ``/bot/v3/info``. This is the bot's own
|
||||
open_id *within its app context* and is what Feishu
|
||||
puts in ``mentions[].id.open_id`` when someone
|
||||
@-mentions the bot. Used for mention gating only.
|
||||
|
||||
In single-bot mode (what Hermes currently supports), open_id works as a
|
||||
de-facto unique user identifier since there is only one app context.
|
||||
|
||||
Session-key participant isolation prefers ``union_id`` (via user_id_alt)
|
||||
over ``open_id`` (via user_id) so that sessions stay stable if the same
|
||||
user is seen through different apps in the future.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -327,7 +356,7 @@ class FeishuNormalizedMessage:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeishuAdapterSettings:
|
||||
app_id: str
|
||||
app_id: str # Canonical bot/app identifier (credential, not from event payloads)
|
||||
app_secret: str
|
||||
domain_name: str
|
||||
connection_mode: str
|
||||
@@ -335,7 +364,11 @@ class FeishuAdapterSettings:
|
||||
verification_token: str
|
||||
group_policy: str
|
||||
allowed_group_users: frozenset[str]
|
||||
# Bot's own open_id (app-scoped) — returned by /bot/v3/info. Used only for
|
||||
# @mention matching: Feishu puts this value in mentions[].id.open_id when
|
||||
# a user @-mentions the bot in a group chat.
|
||||
bot_open_id: str
|
||||
# Bot's user_id (tenant-scoped) — optional, used as fallback mention match.
|
||||
bot_user_id: str
|
||||
bot_name: str
|
||||
dedup_cache_size: int
|
||||
@@ -1667,6 +1700,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
content = self.format_message(content)
|
||||
try:
|
||||
msg_type, payload = self._build_outbound_payload(content)
|
||||
body = self._build_update_message_body(msg_type=msg_type, content=payload)
|
||||
@@ -3414,10 +3448,22 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
return "group"
|
||||
|
||||
async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]:
|
||||
"""Map Feishu's three-tier user IDs onto Hermes' SessionSource fields.
|
||||
|
||||
Preference order for the primary ``user_id`` field:
|
||||
1. user_id (tenant-scoped, most stable — requires permission scope)
|
||||
2. open_id (app-scoped, always available — different per bot app)
|
||||
|
||||
``user_id_alt`` carries the union_id (developer-scoped, stable across
|
||||
all apps by the same developer). Session-key generation prefers
|
||||
user_id_alt when present, so participant isolation stays stable even
|
||||
if the primary ID is the app-scoped open_id.
|
||||
"""
|
||||
open_id = getattr(sender_id, "open_id", None) or None
|
||||
user_id = getattr(sender_id, "user_id", None) or None
|
||||
union_id = getattr(sender_id, "union_id", None) or None
|
||||
primary_id = open_id or user_id
|
||||
# Prefer tenant-scoped user_id; fall back to app-scoped open_id.
|
||||
primary_id = user_id or open_id
|
||||
display_name = await self._resolve_sender_name_from_api(primary_id or union_id)
|
||||
return {
|
||||
"user_id": primary_id,
|
||||
@@ -4427,6 +4473,9 @@ def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
||||
|
||||
Uses lark_oapi SDK when available, falls back to raw HTTP otherwise.
|
||||
Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure.
|
||||
|
||||
Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID
|
||||
that Feishu puts in @mention payloads. It is NOT the app_id.
|
||||
"""
|
||||
if FEISHU_AVAILABLE:
|
||||
return _probe_bot_sdk(app_id, app_secret, domain)
|
||||
|
||||
@@ -2170,8 +2170,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
ul_match = re.match(r"^[\s]*[-*+]\s+(.+)$", line)
|
||||
if ul_match:
|
||||
items = []
|
||||
while i < len(lines) and re.match(r"^[\s]*[-*+]\s+(.+)$", lines[i]):
|
||||
items.append(re.match(r"^[\s]*[-*+]\s+(.+)$", lines[i]).group(1))
|
||||
while i < len(lines) and (m := re.match(r"^[\s]*[-*+]\s+(.+)$", lines[i])):
|
||||
items.append(m.group(1))
|
||||
i += 1
|
||||
li = "".join(f"<li>{item}</li>" for item in items)
|
||||
out_lines.append(f"<ul>{li}</ul>")
|
||||
@@ -2181,8 +2181,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
ol_match = re.match(r"^[\s]*\d+[.)]\s+(.+)$", line)
|
||||
if ol_match:
|
||||
items = []
|
||||
while i < len(lines) and re.match(r"^[\s]*\d+[.)]\s+(.+)$", lines[i]):
|
||||
items.append(re.match(r"^[\s]*\d+[.)]\s+(.+)$", lines[i]).group(1))
|
||||
while i < len(lines) and (m := re.match(r"^[\s]*\d+[.)]\s+(.+)$", lines[i])):
|
||||
items.append(m.group(1))
|
||||
i += 1
|
||||
li = "".join(f"<li>{item}</li>" for item in items)
|
||||
out_lines.append(f"<ol>{li}</ol>")
|
||||
|
||||
@@ -535,6 +535,9 @@ class QQAdapter(BasePlatformAdapter):
|
||||
quick_disconnect_count = 0
|
||||
else:
|
||||
backoff_idx += 1
|
||||
if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
|
||||
logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag)
|
||||
return
|
||||
|
||||
except Exception as exc:
|
||||
if not self._running:
|
||||
@@ -1839,6 +1842,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(1.5 * (attempt + 1))
|
||||
else:
|
||||
raise
|
||||
raise AssertionError("unreachable: retry loop exhausted")
|
||||
|
||||
# Maximum time (seconds) to wait for reconnection before giving up on send.
|
||||
_RECONNECT_WAIT_SECONDS = 15.0
|
||||
|
||||
@@ -1690,6 +1690,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
raise AssertionError("unreachable: retry loop exhausted")
|
||||
|
||||
async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes:
|
||||
"""Download a Slack file and return raw bytes, with retry."""
|
||||
@@ -1715,6 +1716,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
raise AssertionError("unreachable: retry loop exhausted")
|
||||
|
||||
# ── Channel mention gating ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ import hmac
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
|
||||
@@ -2820,6 +2820,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
sticker = msg.sticker
|
||||
if sticker is None:
|
||||
return
|
||||
emoji = sticker.emoji or ""
|
||||
set_name = sticker.set_name or ""
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ def _resolve_system_dns() -> set[str]:
|
||||
"""Return the IPv4 addresses that the OS resolver gives for api.telegram.org."""
|
||||
try:
|
||||
results = socket.getaddrinfo(_TELEGRAM_API_HOST, 443, socket.AF_INET)
|
||||
return {addr[4][0] for addr in results}
|
||||
return {str(addr[4][0]) for addr in results}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
@@ -508,6 +508,11 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
self._remember_chat_req_id(chat_id, self._payload_req_id(payload))
|
||||
|
||||
text, reply_text = self._extract_text(body)
|
||||
# Strip leading @mention in group chats so slash commands like
|
||||
# "@BotName /approve" are correctly recognized as "/approve".
|
||||
# Mirrors what the Telegram adapter does (re.sub @botname).
|
||||
if is_group and text:
|
||||
text = re.sub(r"^@\S+\s*", "", text).strip()
|
||||
media_urls, media_types = await self._extract_media(body)
|
||||
message_type = self._derive_message_type(body, text, media_types)
|
||||
has_reply_context = bool(reply_text and (text or media_urls))
|
||||
@@ -698,7 +703,8 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
elif isinstance(appmsg.get("image"), dict):
|
||||
refs.append(("image", appmsg["image"]))
|
||||
|
||||
quote = body.get("quote") if isinstance(body.get("quote"), dict) else {}
|
||||
raw_quote = body.get("quote")
|
||||
quote = raw_quote if isinstance(raw_quote, dict) else {}
|
||||
quote_type = str(quote.get("msgtype") or "").lower()
|
||||
if quote_type == "image" and isinstance(quote.get("image"), dict):
|
||||
refs.append(("image", quote["image"]))
|
||||
|
||||
@@ -25,7 +25,10 @@ import subprocess
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Any
|
||||
from typing import Dict, Optional, Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
|
||||
from hermes_constants import get_hermes_dir
|
||||
|
||||
|
||||
+56
-23
@@ -2859,10 +2859,12 @@ class GatewayRunner:
|
||||
return MatrixAdapter(config)
|
||||
|
||||
elif platform == Platform.API_SERVER:
|
||||
from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
|
||||
if not check_api_server_requirements():
|
||||
try:
|
||||
import aiohttp # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning("API Server: aiohttp not installed")
|
||||
return None
|
||||
from gateway.platforms.api_server import APIServerAdapter
|
||||
return APIServerAdapter(config)
|
||||
|
||||
elif platform == Platform.WEBHOOK:
|
||||
@@ -4429,9 +4431,10 @@ class GatewayRunner:
|
||||
# is speaking, without needing a separate tool call.
|
||||
# -----------------------------------------------------------------
|
||||
if source.platform == Platform.DISCORD:
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
adapter = self.adapters.get(Platform.DISCORD)
|
||||
guild_id = self._get_guild_id(event)
|
||||
if guild_id and adapter and hasattr(adapter, "get_voice_channel_context"):
|
||||
if guild_id and isinstance(adapter, DiscordAdapter):
|
||||
vc_context = adapter.get_voice_channel_context(guild_id)
|
||||
if vc_context:
|
||||
context_prompt += f"\n\n{vc_context}"
|
||||
@@ -4971,6 +4974,11 @@ class GatewayRunner:
|
||||
# the configured default instead of the previously switched model.
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
|
||||
# Clear session-scoped dangerous-command approvals and /yolo state.
|
||||
# /new is a conversation-boundary operation — approval state from the
|
||||
# previous conversation must not survive the reset.
|
||||
self._clear_session_boundary_security_state(session_key)
|
||||
|
||||
# Fire plugin on_session_finalize hook (session boundary)
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
@@ -5479,6 +5487,7 @@ class GatewayRunner:
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=50,
|
||||
@@ -5590,6 +5599,7 @@ class GatewayRunner:
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
user_providers=user_provs,
|
||||
custom_providers=custom_provs,
|
||||
max_models=5,
|
||||
@@ -5867,7 +5877,7 @@ class GatewayRunner:
|
||||
available = "`none`, " + ", ".join(f"`{n}`" for n in personalities)
|
||||
return f"Unknown personality: `{args}`\n\nAvailable: {available}"
|
||||
|
||||
async def _handle_retry_command(self, event: MessageEvent) -> str:
|
||||
async def _handle_retry_command(self, event: MessageEvent) -> Optional[str]:
|
||||
"""Handle /retry command - re-send the last user message."""
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
@@ -6017,9 +6027,10 @@ class GatewayRunner:
|
||||
"all": "TTS (voice reply to all messages)",
|
||||
}
|
||||
# Append voice channel info if connected
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
adapter = self.adapters.get(event.source.platform)
|
||||
guild_id = self._get_guild_id(event)
|
||||
if guild_id and hasattr(adapter, "get_voice_channel_info"):
|
||||
if guild_id and isinstance(adapter, DiscordAdapter):
|
||||
info = adapter.get_voice_channel_info(guild_id)
|
||||
if info:
|
||||
lines = [
|
||||
@@ -6050,8 +6061,9 @@ class GatewayRunner:
|
||||
|
||||
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
|
||||
"""Join the user's current Discord voice channel."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
adapter = self.adapters.get(event.source.platform)
|
||||
if not hasattr(adapter, "join_voice_channel"):
|
||||
if not isinstance(adapter, DiscordAdapter):
|
||||
return "Voice channels are not supported on this platform."
|
||||
|
||||
guild_id = self._get_guild_id(event)
|
||||
@@ -6066,10 +6078,8 @@ class GatewayRunner:
|
||||
|
||||
# Wire callbacks BEFORE join so voice input arriving immediately
|
||||
# after connection is not lost.
|
||||
if hasattr(adapter, "_voice_input_callback"):
|
||||
adapter._voice_input_callback = self._handle_voice_channel_input
|
||||
if hasattr(adapter, "_on_voice_disconnect"):
|
||||
adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
|
||||
adapter._voice_input_callback = self._handle_voice_channel_input
|
||||
adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
|
||||
|
||||
try:
|
||||
success = await adapter.join_voice_channel(voice_channel)
|
||||
@@ -6086,8 +6096,7 @@ class GatewayRunner:
|
||||
|
||||
if success:
|
||||
adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
|
||||
if hasattr(adapter, "_voice_sources"):
|
||||
adapter._voice_sources[guild_id] = event.source.to_dict()
|
||||
adapter._voice_sources[guild_id] = event.source.to_dict()
|
||||
self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "all"
|
||||
self._save_voice_modes()
|
||||
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
|
||||
@@ -6101,13 +6110,14 @@ class GatewayRunner:
|
||||
|
||||
async def _handle_voice_channel_leave(self, event: MessageEvent) -> str:
|
||||
"""Leave the Discord voice channel."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
adapter = self.adapters.get(event.source.platform)
|
||||
guild_id = self._get_guild_id(event)
|
||||
|
||||
if not guild_id or not hasattr(adapter, "leave_voice_channel"):
|
||||
if not guild_id or not isinstance(adapter, DiscordAdapter):
|
||||
return "Not in a voice channel."
|
||||
|
||||
if not hasattr(adapter, "is_in_voice_channel") or not adapter.is_in_voice_channel(guild_id):
|
||||
if not adapter.is_in_voice_channel(guild_id):
|
||||
return "Not in a voice channel."
|
||||
|
||||
try:
|
||||
@@ -6118,8 +6128,7 @@ class GatewayRunner:
|
||||
self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "off"
|
||||
self._save_voice_modes()
|
||||
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)
|
||||
if hasattr(adapter, "_voice_input_callback"):
|
||||
adapter._voice_input_callback = None
|
||||
adapter._voice_input_callback = None
|
||||
return "Left voice channel."
|
||||
|
||||
def _handle_voice_timeout_cleanup(self, chat_id: str) -> None:
|
||||
@@ -6279,13 +6288,13 @@ class GatewayRunner:
|
||||
adapter = self.adapters.get(event.source.platform)
|
||||
|
||||
# If connected to a voice channel, play there instead of sending a file
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
guild_id = self._get_guild_id(event)
|
||||
if (guild_id
|
||||
and hasattr(adapter, "play_in_voice_channel")
|
||||
and hasattr(adapter, "is_in_voice_channel")
|
||||
and isinstance(adapter, DiscordAdapter)
|
||||
and adapter.is_in_voice_channel(guild_id)):
|
||||
await adapter.play_in_voice_channel(guild_id, actual_path)
|
||||
elif adapter and hasattr(adapter, "send_voice"):
|
||||
elif adapter:
|
||||
send_kwargs: Dict[str, Any] = {
|
||||
"chat_id": event.source.chat_id,
|
||||
"audio_path": actual_path,
|
||||
@@ -7217,6 +7226,7 @@ class GatewayRunner:
|
||||
new_entry = self.session_store.switch_session(session_key, target_id)
|
||||
if not new_entry:
|
||||
return "Failed to switch session."
|
||||
self._clear_session_boundary_security_state(session_key)
|
||||
|
||||
# Get the title for confirmation
|
||||
title = self._session_db.get_session_title(target_id) or name
|
||||
@@ -7306,6 +7316,7 @@ class GatewayRunner:
|
||||
new_entry = self.session_store.switch_session(session_key, new_session_id)
|
||||
if not new_entry:
|
||||
return "Branch created but failed to switch to it."
|
||||
self._clear_session_boundary_security_state(session_key)
|
||||
|
||||
# Evict any cached agent for this session
|
||||
self._evict_cached_agent(session_key)
|
||||
@@ -8680,6 +8691,29 @@ class GatewayRunner:
|
||||
if hasattr(self, "_busy_ack_ts"):
|
||||
self._busy_ack_ts.pop(session_key, None)
|
||||
|
||||
def _clear_session_boundary_security_state(self, session_key: str) -> None:
|
||||
"""Clear approval state that must not survive a real conversation switch."""
|
||||
if not session_key:
|
||||
return
|
||||
|
||||
pending_approvals = getattr(self, "_pending_approvals", None)
|
||||
if isinstance(pending_approvals, dict):
|
||||
pending_approvals.pop(session_key, None)
|
||||
|
||||
try:
|
||||
from tools.approval import clear_session as _clear_approval_session
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
_clear_approval_session(session_key)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to clear approval state for session boundary %s: %s",
|
||||
session_key,
|
||||
e,
|
||||
)
|
||||
|
||||
def _begin_session_run_generation(self, session_key: str) -> int:
|
||||
"""Claim a fresh run generation token for ``session_key``.
|
||||
|
||||
@@ -10456,6 +10490,7 @@ class GatewayRunner:
|
||||
if _timed_out_agent and hasattr(_timed_out_agent, "interrupt"):
|
||||
_timed_out_agent.interrupt(_INTERRUPT_REASON_TIMEOUT)
|
||||
|
||||
assert _agent_timeout is not None # narrowed by _idle_secs >= _agent_timeout above
|
||||
_timeout_mins = int(_agent_timeout // 60) or 1
|
||||
|
||||
# Construct a user-facing message with diagnostic context.
|
||||
@@ -10574,7 +10609,7 @@ class GatewayRunner:
|
||||
pending = None
|
||||
|
||||
if pending_event or pending:
|
||||
logger.debug("Processing pending message: '%s...'", pending[:40])
|
||||
logger.debug("Processing pending message: '%s...'", (pending or "")[:40])
|
||||
|
||||
# Clear the adapter's interrupt event so the next _run_agent call
|
||||
# doesn't immediately re-trigger the interrupt before the new agent
|
||||
@@ -10593,8 +10628,6 @@ class GatewayRunner:
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter and pending_event:
|
||||
merge_pending_message_event(adapter._pending_messages, session_key, pending_event)
|
||||
elif adapter and hasattr(adapter, 'queue_message'):
|
||||
adapter.queue_message(session_key, pending)
|
||||
return result_holder[0] or {"final_response": response, "messages": history}
|
||||
|
||||
was_interrupted = result.get("interrupted")
|
||||
@@ -10676,7 +10709,7 @@ class GatewayRunner:
|
||||
history=updated_history,
|
||||
)
|
||||
if next_message is None:
|
||||
return result
|
||||
return result # ty: ignore[invalid-return-type]
|
||||
next_message_id = getattr(pending_event, "message_id", None)
|
||||
next_channel_prompt = getattr(pending_event, "channel_prompt", None)
|
||||
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class SessionSource:
|
||||
user_name: Optional[str] = None
|
||||
thread_id: Optional[str] = None # For forum topics, Discord threads, etc.
|
||||
chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack)
|
||||
user_id_alt: Optional[str] = None # Signal UUID (alternative to phone number)
|
||||
user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id)
|
||||
chat_id_alt: Optional[str] = None # Signal group internal ID
|
||||
is_bot: bool = False # True when the message author is a bot/webhook (Discord)
|
||||
|
||||
|
||||
+7
-1
@@ -136,6 +136,7 @@ def _looks_like_gateway_process(pid: int) -> bool:
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli/main.py gateway",
|
||||
"hermes gateway",
|
||||
"hermes-gateway",
|
||||
"gateway/run.py",
|
||||
)
|
||||
return any(pattern in cmdline for pattern in patterns)
|
||||
@@ -495,7 +496,8 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str,
|
||||
if not stale:
|
||||
try:
|
||||
os.kill(existing_pid, 0)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
# Windows raises OSError with WinError 87 for invalid pid check
|
||||
stale = True
|
||||
else:
|
||||
current_start = _get_process_start_time(existing_pid)
|
||||
@@ -742,6 +744,10 @@ def get_running_pid(
|
||||
if _record_looks_like_gateway(record):
|
||||
return pid
|
||||
continue
|
||||
except OSError:
|
||||
# Windows raises OSError with WinError 87 for an invalid pid
|
||||
# (process is definitely gone). Treat as "process doesn't exist".
|
||||
continue
|
||||
|
||||
recorded_start = record.get("start_time")
|
||||
current_start = _get_process_start_time(pid)
|
||||
|
||||
+11
-6
@@ -214,6 +214,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://api.anthropic.com",
|
||||
api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
|
||||
base_url_env_var="ANTHROPIC_BASE_URL",
|
||||
),
|
||||
"alibaba": ProviderConfig(
|
||||
id="alibaba",
|
||||
@@ -767,16 +768,20 @@ def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Di
|
||||
auth_store["active_provider"] = provider_id
|
||||
|
||||
|
||||
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return the persisted credential pool, or one provider slice."""
|
||||
def read_credential_pool() -> Dict[str, Any]:
|
||||
"""Return the entire persisted credential pool."""
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
if provider_id is None:
|
||||
return dict(pool)
|
||||
provider_entries = pool.get(provider_id)
|
||||
return list(provider_entries) if isinstance(provider_entries, list) else []
|
||||
return dict(pool)
|
||||
|
||||
|
||||
def read_provider_credentials(provider_id: str) -> List[Dict[str, Any]]:
|
||||
"""Return credential entries for a single provider."""
|
||||
pool = read_credential_pool()
|
||||
entries = pool.get(provider_id)
|
||||
return list(entries) if isinstance(entries, list) else []
|
||||
|
||||
|
||||
def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
|
||||
state_path = child / state_name
|
||||
if state_path.exists():
|
||||
kind = "directory" if state_path.is_dir() else "file"
|
||||
rel = state_path.relative_to(source_dir)
|
||||
rel = state_path.relative_to(source_dir).as_posix()
|
||||
findings.append((state_path, f"Workspace {kind}: {rel}"))
|
||||
|
||||
return findings
|
||||
|
||||
@@ -276,7 +276,7 @@ def _get_ps_exe() -> str | None:
|
||||
global _ps_exe
|
||||
if _ps_exe is False:
|
||||
_ps_exe = _find_powershell()
|
||||
return _ps_exe
|
||||
return _ps_exe if isinstance(_ps_exe, str) else None
|
||||
|
||||
|
||||
def _windows_has_image() -> bool:
|
||||
@@ -387,6 +387,8 @@ def _wayland_save(dest: Path) -> bool:
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("wl-paste not installed — Wayland clipboard unavailable")
|
||||
except ImportError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("wl-paste clipboard extraction failed: %s", e)
|
||||
dest.unlink(missing_ok=True)
|
||||
@@ -395,14 +397,17 @@ def _wayland_save(dest: Path) -> bool:
|
||||
|
||||
def _convert_to_png(path: Path) -> bool:
|
||||
"""Convert an image file to PNG in-place (requires Pillow or ImageMagick)."""
|
||||
# Try Pillow first (likely installed in the venv)
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Pillow is required for clipboard image conversion. "
|
||||
"Install with: pip install hermes-agent[cli]"
|
||||
) from None
|
||||
try:
|
||||
img = Image.open(path)
|
||||
img.save(path, "PNG")
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Pillow BMP→PNG conversion failed: %s", e)
|
||||
|
||||
|
||||
+19
-5
@@ -712,6 +712,12 @@ DEFAULT_CONFIG = {
|
||||
"provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials)
|
||||
"base_url": "", # direct OpenAI-compatible endpoint for subagents
|
||||
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
|
||||
# When delegate_task narrows child toolsets explicitly, preserve any
|
||||
# MCP toolsets the parent already has enabled. On by default so
|
||||
# narrowing (e.g. toolsets=["web","browser"]) expresses "I want these
|
||||
# extras" without silently stripping MCP tools the parent already has.
|
||||
# Set to false for strict intersection.
|
||||
"inherit_mcp_toolsets": True,
|
||||
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
|
||||
# independent of the parent's max_iterations)
|
||||
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
||||
@@ -1898,7 +1904,7 @@ def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||||
config = load_config()
|
||||
missing = []
|
||||
|
||||
def _check(defaults: dict, current: dict, prefix: str = ""):
|
||||
def _check(defaults: Dict[str, Any], current: Dict[str, Any], prefix: str = ""):
|
||||
for key, default_value in defaults.items():
|
||||
if key.startswith('_'):
|
||||
continue
|
||||
@@ -2049,6 +2055,14 @@ def _normalize_custom_provider_entry(
|
||||
models = entry.get("models")
|
||||
if isinstance(models, dict) and models:
|
||||
normalized["models"] = models
|
||||
elif isinstance(models, list) and models:
|
||||
# Hand-edited configs (and older Hermes versions) write ``models`` as
|
||||
# a plain list of model ids. Preserve them by converting to the dict
|
||||
# shape downstream code expects; otherwise normalize silently drops
|
||||
# the list and /model shows the provider with (0) models.
|
||||
normalized["models"] = {
|
||||
str(m): {} for m in models if isinstance(m, str) and m.strip()
|
||||
}
|
||||
|
||||
context_length = entry.get("context_length")
|
||||
if isinstance(context_length, int) and context_length > 0:
|
||||
@@ -2132,8 +2146,8 @@ def check_config_version() -> Tuple[int, int]:
|
||||
Returns (current_version, latest_version).
|
||||
"""
|
||||
config = load_config()
|
||||
current = config.get("_config_version", 0)
|
||||
latest = DEFAULT_CONFIG.get("_config_version", 1)
|
||||
current = int(config.get("_config_version", 0))
|
||||
latest = int(DEFAULT_CONFIG.get("_config_version", 1))
|
||||
return current, latest
|
||||
|
||||
|
||||
@@ -2853,7 +2867,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
return results
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict) -> dict:
|
||||
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Recursively merge *override* into *base*, preserving nested defaults.
|
||||
|
||||
Keys in *override* take precedence. If both values are dicts the merge
|
||||
@@ -3163,7 +3177,7 @@ def save_config(config: Dict[str, Any]):
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
fb = normalized.get("fallback_model", {})
|
||||
if not fb or not (fb.get("provider") and fb.get("model")):
|
||||
if not fb or not isinstance(fb, dict) or not (fb.get("provider") and fb.get("model")):
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
|
||||
atomic_yaml_write(
|
||||
|
||||
@@ -18,7 +18,7 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
@@ -108,7 +108,7 @@ def wait_for_registration_success(
|
||||
device_code: str,
|
||||
interval: int = 3,
|
||||
expires_in: int = 7200,
|
||||
on_waiting: Optional[callable] = None,
|
||||
on_waiting: Optional[Callable[..., Any]] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Block until the registration succeeds or times out.
|
||||
|
||||
|
||||
@@ -761,6 +761,21 @@ def get_systemd_unit_path(system: bool = False) -> Path:
|
||||
return Path.home() / ".config" / "systemd" / "user" / f"{name}.service"
|
||||
|
||||
|
||||
class UserSystemdUnavailableError(RuntimeError):
|
||||
"""Raised when ``systemctl --user`` cannot reach the user D-Bus session.
|
||||
|
||||
Typically hit on fresh RHEL/Debian SSH sessions where linger is disabled
|
||||
and no user@.service is running, so ``/run/user/$UID/bus`` never exists.
|
||||
Carries a user-facing remediation message in ``args[0]``.
|
||||
"""
|
||||
|
||||
|
||||
def _user_dbus_socket_path() -> Path:
|
||||
"""Return the expected per-user D-Bus socket path (regardless of existence)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
return Path(xdg) / "bus"
|
||||
|
||||
|
||||
def _ensure_user_systemd_env() -> None:
|
||||
"""Ensure DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR are set for systemctl --user.
|
||||
|
||||
@@ -783,6 +798,126 @@ def _ensure_user_systemd_env() -> None:
|
||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={bus_path}"
|
||||
|
||||
|
||||
def _wait_for_user_dbus_socket(timeout: float = 3.0) -> bool:
|
||||
"""Poll for the user D-Bus socket to appear, up to ``timeout`` seconds.
|
||||
|
||||
Linger-enabled user@.service can take a second or two to spawn the socket
|
||||
after ``loginctl enable-linger`` runs. Returns True once the socket exists.
|
||||
"""
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if _user_dbus_socket_path().exists():
|
||||
_ensure_user_systemd_env()
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return _user_dbus_socket_path().exists()
|
||||
|
||||
|
||||
def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
"""Ensure ``systemctl --user`` will reach the user D-Bus session bus.
|
||||
|
||||
No-op when the bus socket is already there (the common case on desktops
|
||||
and linger-enabled servers). On fresh SSH sessions where the socket is
|
||||
missing:
|
||||
|
||||
* If linger is already enabled, wait briefly for user@.service to spawn
|
||||
the socket.
|
||||
* If linger is disabled and ``auto_enable_linger`` is True, try
|
||||
``loginctl enable-linger $USER`` (works as non-root when polkit permits
|
||||
it, otherwise needs sudo).
|
||||
* If the socket is still missing afterwards, raise
|
||||
:class:`UserSystemdUnavailableError` with a precise remediation message.
|
||||
|
||||
Callers should treat the exception as a terminal condition for user-scope
|
||||
systemd operations and surface the message to the user.
|
||||
"""
|
||||
_ensure_user_systemd_env()
|
||||
bus_path = _user_dbus_socket_path()
|
||||
if bus_path.exists():
|
||||
return
|
||||
|
||||
import getpass
|
||||
|
||||
username = getpass.getuser()
|
||||
linger_enabled, linger_detail = get_systemd_linger_status()
|
||||
|
||||
if linger_enabled is True:
|
||||
if _wait_for_user_dbus_socket(timeout=3.0):
|
||||
return
|
||||
# Linger is on but socket still missing — unusual; fall through to error.
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason="User D-Bus socket is missing even though linger is enabled.",
|
||||
fix_hint=(
|
||||
f" systemctl start user@{os.getuid()}.service\n"
|
||||
" (may require sudo; try again after the command succeeds)"
|
||||
),
|
||||
)
|
||||
|
||||
if auto_enable_linger and shutil.which("loginctl"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["loginctl", "enable-linger", username],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason=f"loginctl enable-linger failed ({exc}).",
|
||||
fix_hint=f" sudo loginctl enable-linger {username}",
|
||||
)
|
||||
else:
|
||||
if result.returncode == 0:
|
||||
if _wait_for_user_dbus_socket(timeout=5.0):
|
||||
print(f"✓ Enabled linger for {username} — user D-Bus now available")
|
||||
return
|
||||
# enable-linger succeeded but the socket never appeared.
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason="Linger was enabled, but the user D-Bus socket did not appear.",
|
||||
fix_hint=(
|
||||
" Log out and log back in, then re-run the command.\n"
|
||||
f" Or reboot and run: systemctl --user start {get_service_name()}"
|
||||
),
|
||||
)
|
||||
detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip()
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason=f"loginctl enable-linger was denied: {detail}",
|
||||
fix_hint=f" sudo loginctl enable-linger {username}",
|
||||
)
|
||||
|
||||
_raise_user_systemd_unavailable(
|
||||
username,
|
||||
reason=(
|
||||
"User D-Bus session is not available "
|
||||
f"({linger_detail or 'linger disabled'})."
|
||||
),
|
||||
fix_hint=f" sudo loginctl enable-linger {username}",
|
||||
)
|
||||
|
||||
|
||||
def _raise_user_systemd_unavailable(username: str, *, reason: str, fix_hint: str) -> None:
|
||||
"""Build a user-facing error message and raise UserSystemdUnavailableError."""
|
||||
msg = (
|
||||
f"{reason}\n"
|
||||
" systemctl --user cannot reach the user D-Bus session in this shell.\n"
|
||||
"\n"
|
||||
" To fix:\n"
|
||||
f"{fix_hint}\n"
|
||||
"\n"
|
||||
" Alternative: run the gateway in the foreground (stays up until\n"
|
||||
" you exit / close the terminal):\n"
|
||||
" hermes gateway run"
|
||||
)
|
||||
raise UserSystemdUnavailableError(msg)
|
||||
|
||||
|
||||
def _systemctl_cmd(system: bool = False) -> list[str]:
|
||||
if not system:
|
||||
_ensure_user_systemd_env()
|
||||
@@ -1623,6 +1758,11 @@ def systemd_start(system: bool = False):
|
||||
system = _select_systemd_scope(system)
|
||||
if system:
|
||||
_require_root_for_system_service("start")
|
||||
else:
|
||||
# Fail fast with actionable guidance if the user D-Bus session is not
|
||||
# reachable (common on fresh RHEL/Debian SSH sessions without linger).
|
||||
# Raises UserSystemdUnavailableError with a remediation message.
|
||||
_preflight_user_systemd()
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service started")
|
||||
@@ -1642,6 +1782,8 @@ def systemd_restart(system: bool = False):
|
||||
system = _select_systemd_scope(system)
|
||||
if system:
|
||||
_require_root_for_system_service("restart")
|
||||
else:
|
||||
_preflight_user_systemd()
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
@@ -2905,6 +3047,12 @@ def _setup_wecom():
|
||||
print_success("💬 WeCom configured!")
|
||||
|
||||
|
||||
def _setup_wecom_callback():
|
||||
"""Configure WeCom Callback (self-built app) via the standard platform setup."""
|
||||
wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom_callback")
|
||||
_setup_standard_platform(wecom_platform)
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if supports_systemd_services():
|
||||
@@ -3516,6 +3664,10 @@ def gateway_setup():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Failed to start — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Failed to start: {e}")
|
||||
else:
|
||||
@@ -3580,6 +3732,10 @@ def gateway_setup():
|
||||
else:
|
||||
stop_profile_gateway()
|
||||
print_info("Start manually: hermes gateway")
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
@@ -3589,6 +3745,10 @@ def gateway_setup():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
else:
|
||||
@@ -3612,6 +3772,10 @@ def gateway_setup():
|
||||
systemd_start(system=installed_scope == "system")
|
||||
else:
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -3649,6 +3813,18 @@ def gateway_setup():
|
||||
|
||||
def gateway_command(args):
|
||||
"""Handle gateway subcommands."""
|
||||
try:
|
||||
return _gateway_command_inner(args)
|
||||
except UserSystemdUnavailableError as e:
|
||||
# Clean, actionable message instead of a traceback when the user D-Bus
|
||||
# session is unreachable (fresh SSH shell, no linger, container, etc.).
|
||||
print_error("User systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
subcmd = getattr(args, 'gateway_command', None)
|
||||
|
||||
# Default to run if no subcommand
|
||||
|
||||
@@ -1131,6 +1131,20 @@ def cmd_chat(args):
|
||||
if getattr(args, "yolo", False):
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
|
||||
# --ignore-user-config: make load_cli_config() / load_config() skip the
|
||||
# user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE
|
||||
# importing cli (which runs `CLI_CONFIG = load_cli_config()` at module
|
||||
# import time). Credentials in .env are still loaded — this flag only
|
||||
# ignores behavioral/config settings.
|
||||
if getattr(args, "ignore_user_config", False):
|
||||
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
|
||||
|
||||
# --ignore-rules: skip auto-injection of AGENTS.md/SOUL.md/.cursorrules
|
||||
# (rules), memory entries, and any preloaded skills coming from user config.
|
||||
# Maps to AIAgent(skip_context_files=True, skip_memory=True).
|
||||
if getattr(args, "ignore_rules", False):
|
||||
os.environ["HERMES_IGNORE_RULES"] = "1"
|
||||
|
||||
# --source: tag session source for filtering (e.g. 'tool' for third-party integrations)
|
||||
if getattr(args, "source", None):
|
||||
os.environ["HERMES_SESSION_SOURCE"] = args.source
|
||||
@@ -1159,6 +1173,8 @@ def cmd_chat(args):
|
||||
"checkpoints": getattr(args, "checkpoints", False),
|
||||
"pass_session_id": getattr(args, "pass_session_id", False),
|
||||
"max_turns": getattr(args, "max_turns", None),
|
||||
"ignore_rules": getattr(args, "ignore_rules", False),
|
||||
"ignore_user_config": getattr(args, "ignore_user_config", False),
|
||||
}
|
||||
# Filter out None values
|
||||
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
@@ -6606,6 +6622,18 @@ For more help on a command:
|
||||
default=False,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tui",
|
||||
action="store_true",
|
||||
@@ -6745,6 +6773,18 @@ For more help on a command:
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-user-config",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--ignore-rules",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--source",
|
||||
default=None,
|
||||
|
||||
+93
-29
@@ -782,6 +782,7 @@ def switch_model(
|
||||
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int = 8,
|
||||
@@ -810,7 +811,10 @@ def list_authenticated_providers(
|
||||
get_provider_info as _mdev_pinfo,
|
||||
)
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS
|
||||
from hermes_cli.models import (
|
||||
OPENROUTER_MODELS, _PROVIDER_MODELS,
|
||||
_MODELS_DEV_PREFERRED, _merge_with_models_dev,
|
||||
)
|
||||
|
||||
results: List[dict] = []
|
||||
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
|
||||
@@ -844,6 +848,10 @@ def list_authenticated_providers(
|
||||
# source of truth. models.dev can have wrong mappings (e.g.
|
||||
# minimax-cn → MINIMAX_API_KEY instead of MINIMAX_CN_API_KEY).
|
||||
pconfig = PROVIDER_REGISTRY.get(hermes_id)
|
||||
# Skip non-API-key auth providers here — they are handled in
|
||||
# section 2 (HERMES_OVERLAYS) with proper auth store checking.
|
||||
if pconfig and pconfig.auth_type != "api_key":
|
||||
continue
|
||||
if pconfig and pconfig.api_key_env_vars:
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
else:
|
||||
@@ -856,8 +864,13 @@ def list_authenticated_providers(
|
||||
if not has_creds:
|
||||
continue
|
||||
|
||||
# Use curated list, falling back to models.dev if no curated list
|
||||
# Use curated list, falling back to models.dev if no curated list.
|
||||
# For preferred providers, merge models.dev entries into the curated
|
||||
# catalog so newly released models (e.g. mimo-v2.5-pro on opencode-go)
|
||||
# show up in the picker without requiring a Hermes release.
|
||||
model_ids = curated.get(hermes_id, [])
|
||||
if hermes_id in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_id, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
@@ -961,6 +974,9 @@ def list_authenticated_providers(
|
||||
|
||||
# Use curated list — look up by Hermes slug, fall back to overlay key
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
# Merge with models.dev for preferred providers (same rationale as above).
|
||||
if hermes_slug in _MODELS_DEV_PREFERRED:
|
||||
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
|
||||
total = len(model_ids)
|
||||
top = model_ids[:max_models]
|
||||
|
||||
@@ -1106,66 +1122,113 @@ def list_authenticated_providers(
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same provider name are grouped into a
|
||||
# single picker row so that e.g. four Ollama Cloud entries
|
||||
# (qwen3-coder, glm-5.1, kimi-k2, minimax-m2.7) appear as one
|
||||
# "Ollama Cloud" row with four models inside instead of four
|
||||
# duplicate "Ollama Cloud" rows. Entries with distinct provider names
|
||||
# still produce separate rows (e.g. Ollama Cloud vs Moonshot).
|
||||
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
|
||||
# are grouped into a single picker row, so e.g. four Ollama entries
|
||||
# pointing at ``http://localhost:11434/v1`` with per-model display names
|
||||
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# "Ollama" row with four models inside instead of four near-duplicates
|
||||
# that differ only by suffix. Entries with distinct endpoints still
|
||||
# produce separate rows.
|
||||
#
|
||||
# When the grouped endpoint matches ``current_base_url`` the group's
|
||||
# slug becomes ``current_provider`` so that selecting a model from the
|
||||
# picker flows back through the runtime provider that already holds
|
||||
# valid credentials — no re-resolution needed.
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
from collections import OrderedDict
|
||||
|
||||
groups: "OrderedDict[str, dict]" = OrderedDict()
|
||||
# Key by (base_url, api_key) instead of slug: names frequently
|
||||
# differ per model ("Ollama — X") while the endpoint stays the
|
||||
# same. Slug-based grouping left them as separate rows.
|
||||
groups: "OrderedDict[tuple, dict]" = OrderedDict()
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
display_name = (entry.get("name") or "").strip()
|
||||
raw_name = (entry.get("name") or "").strip()
|
||||
api_url = (
|
||||
entry.get("base_url", "")
|
||||
or entry.get("url", "")
|
||||
or entry.get("api", "")
|
||||
or ""
|
||||
).strip()
|
||||
if not display_name or not api_url:
|
||||
).strip().rstrip("/")
|
||||
if not raw_name or not api_url:
|
||||
continue
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
|
||||
slug = custom_provider_slug(display_name)
|
||||
if slug not in groups:
|
||||
groups[slug] = {
|
||||
group_key = (api_url, api_key)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
# "Ollama" for the grouped row. Em dash is the convention
|
||||
# Hermes's own writer uses; a hyphen variant is accepted
|
||||
# for hand-edited configs.
|
||||
display_name = raw_name
|
||||
for sep in ("—", " - "):
|
||||
if sep in display_name:
|
||||
display_name = display_name.split(sep)[0].strip()
|
||||
break
|
||||
if not display_name:
|
||||
display_name = raw_name
|
||||
# If this endpoint matches the currently active one, use
|
||||
# ``current_provider`` as the slug so picker-driven switches
|
||||
# route through the live credential pipeline.
|
||||
if (
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
slug = current_provider or custom_provider_slug(display_name)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"api_url": api_url,
|
||||
"models": [],
|
||||
}
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
# stores every configured model as a dict under ``models:``;
|
||||
# downstream readers (agent/models_dev.py, gateway/run.py,
|
||||
# run_agent.py, hermes_cli/config.py) already consume that dict.
|
||||
# The /model picker previously ignored it, so multi-model
|
||||
# custom providers appeared to have only the active model.
|
||||
default_model = (entry.get("model") or "").strip()
|
||||
if default_model and default_model not in groups[slug]["models"]:
|
||||
groups[slug]["models"].append(default_model)
|
||||
if default_model and default_model not in groups[group_key]["models"]:
|
||||
groups[group_key]["models"].append(default_model)
|
||||
|
||||
cfg_models = entry.get("models", {})
|
||||
if isinstance(cfg_models, dict):
|
||||
for m in cfg_models:
|
||||
if m and m not in groups[slug]["models"]:
|
||||
groups[slug]["models"].append(m)
|
||||
if m and m not in groups[group_key]["models"]:
|
||||
groups[group_key]["models"].append(m)
|
||||
elif isinstance(cfg_models, list):
|
||||
for m in cfg_models:
|
||||
if m and m not in groups[slug]["models"]:
|
||||
groups[slug]["models"].append(m)
|
||||
if m and m not in groups[group_key]["models"]:
|
||||
groups[group_key]["models"].append(m)
|
||||
|
||||
for slug, grp in groups.items():
|
||||
if slug.lower() in seen_slugs:
|
||||
_section4_emitted_slugs: set = set()
|
||||
for grp in groups.values():
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
# to avoid shadowing a real provider.
|
||||
if slug.lower() in seen_slugs and slug.lower() not in _section4_emitted_slugs:
|
||||
continue
|
||||
# If a prior section-4 group already used this slug (two custom
|
||||
# endpoints with the same cleaned name — e.g. two OpenAI-
|
||||
# compatible gateways named identically with different keys),
|
||||
# append a counter so both rows stay visible in the picker.
|
||||
if slug.lower() in _section4_emitted_slugs:
|
||||
base_slug = slug
|
||||
n = 2
|
||||
while f"{base_slug}-{n}".lower() in seen_slugs:
|
||||
n += 1
|
||||
slug = f"{base_slug}-{n}"
|
||||
grp["slug"] = slug
|
||||
# Skip if section 3 already emitted this endpoint under its
|
||||
# ``providers:`` dict key — matches on (display_name, base_url),
|
||||
# the tuple section 4 groups by. Prevents two picker rows
|
||||
# labelled identically when callers pass both ``user_providers``
|
||||
# and a compatibility-merged ``custom_providers`` list.
|
||||
# ``providers:`` dict key — matches on (display_name, base_url).
|
||||
# Prevents two picker rows labelled identically when callers
|
||||
# pass both ``user_providers`` and a compatibility-merged
|
||||
# ``custom_providers`` list.
|
||||
_pair_key = (
|
||||
str(grp["name"]).strip().lower(),
|
||||
str(grp["api_url"]).strip().rstrip("/").lower(),
|
||||
@@ -1183,6 +1246,7 @@ def list_authenticated_providers(
|
||||
"api_url": grp["api_url"],
|
||||
})
|
||||
seen_slugs.add(slug.lower())
|
||||
_section4_emitted_slugs.add(slug.lower())
|
||||
|
||||
# Sort: current provider first, then by model count descending
|
||||
results.sort(key=lambda r: (not r["is_current"], -r["total_models"]))
|
||||
|
||||
+78
-2
@@ -1589,11 +1589,84 @@ def _resolve_copilot_catalog_api_key() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# Providers where models.dev is treated as authoritative: curated static
|
||||
# lists are kept only as an offline fallback and to capture custom additions
|
||||
# the registry doesn't publish yet. Adding a provider here causes its
|
||||
# curated list to be merged with fresh models.dev entries (fresh first, any
|
||||
# curated-only names appended) for both the CLI and the gateway /model picker.
|
||||
#
|
||||
# DELIBERATELY EXCLUDED:
|
||||
# - "openrouter": curated list is already a hand-picked agentic subset of
|
||||
# OpenRouter's 400+ catalog. Blindly merging would dump everything.
|
||||
# - "nous": curated list and Portal /models endpoint are the source of
|
||||
# truth for the subscription tier.
|
||||
# Also excluded: providers that already have dedicated live-endpoint
|
||||
# branches below (copilot, anthropic, ai-gateway, ollama-cloud, custom,
|
||||
# stepfun, openai-codex) — those paths handle freshness themselves.
|
||||
_MODELS_DEV_PREFERRED: frozenset[str] = frozenset({
|
||||
"opencode-go",
|
||||
"opencode-zen",
|
||||
"deepseek",
|
||||
"kilocode",
|
||||
"fireworks",
|
||||
"mistral",
|
||||
"togetherai",
|
||||
"cohere",
|
||||
"perplexity",
|
||||
"groq",
|
||||
"nvidia",
|
||||
"huggingface",
|
||||
"zai",
|
||||
"gemini",
|
||||
"google",
|
||||
})
|
||||
|
||||
|
||||
def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
|
||||
"""Merge curated list with fresh models.dev entries for a preferred provider.
|
||||
|
||||
Returns models.dev entries first (in models.dev order), then any
|
||||
curated-only entries appended. Preserves case for curated fallbacks
|
||||
(e.g. ``MiniMax-M2.7``) while trusting models.dev for newer variants.
|
||||
|
||||
If models.dev is unreachable or returns nothing, the curated list is
|
||||
returned unchanged — this is the offline/CI fallback path.
|
||||
"""
|
||||
try:
|
||||
from agent.models_dev import list_agentic_models
|
||||
mdev = list_agentic_models(provider)
|
||||
except Exception:
|
||||
mdev = []
|
||||
|
||||
if not mdev:
|
||||
return list(curated)
|
||||
|
||||
# Case-insensitive dedup while preserving order and curated casing.
|
||||
seen_lower: set[str] = set()
|
||||
merged: list[str] = []
|
||||
for mid in mdev:
|
||||
key = str(mid).lower()
|
||||
if key in seen_lower:
|
||||
continue
|
||||
seen_lower.add(key)
|
||||
merged.append(mid)
|
||||
for mid in curated:
|
||||
key = str(mid).lower()
|
||||
if key in seen_lower:
|
||||
continue
|
||||
seen_lower.add(key)
|
||||
merged.append(mid)
|
||||
return merged
|
||||
|
||||
|
||||
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
|
||||
"""Return the best known model catalog for a provider.
|
||||
|
||||
Tries live API endpoints for providers that support them (Codex, Nous),
|
||||
falling back to static lists.
|
||||
falling back to static lists. For providers in ``_MODELS_DEV_PREFERRED``
|
||||
(opencode-go/zen, xiaomi, deepseek, smaller inference providers, etc.),
|
||||
models.dev entries are merged on top of curated so new models released
|
||||
on the platform appear in ``/model`` without a Hermes release.
|
||||
"""
|
||||
normalized = normalize_provider(provider)
|
||||
if normalized == "openrouter":
|
||||
@@ -1659,7 +1732,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
if live:
|
||||
return live
|
||||
return list(_PROVIDER_MODELS.get(normalized, []))
|
||||
curated_static = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
if normalized in _MODELS_DEV_PREFERRED:
|
||||
return _merge_with_models_dev(normalized, curated_static)
|
||||
return curated_static
|
||||
|
||||
|
||||
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||
|
||||
@@ -44,7 +44,7 @@ def _cmd_list(store):
|
||||
for p in pending:
|
||||
print(
|
||||
f" {p['platform']:<12} {p['code']:<10} {p['user_id']:<20} "
|
||||
f"{p.get('user_name', ''):<20} {p['age_minutes']}m ago"
|
||||
f"{(p.get('user_name') or ''):<20} {p['age_minutes']}m ago"
|
||||
)
|
||||
else:
|
||||
print("\n No pending pairing requests.")
|
||||
@@ -54,7 +54,7 @@ def _cmd_list(store):
|
||||
print(f" {'Platform':<12} {'User ID':<20} {'Name':<20}")
|
||||
print(f" {'--------':<12} {'-------':<20} {'----':<20}")
|
||||
for a in approved:
|
||||
print(f" {a['platform']:<12} {a['user_id']:<20} {a.get('user_name', ''):<20}")
|
||||
print(f" {a['platform']:<12} {a['user_id']:<20} {(a.get('user_name') or ''):<20}")
|
||||
else:
|
||||
print("\n No approved users.")
|
||||
|
||||
@@ -69,7 +69,7 @@ def _cmd_approve(store, platform: str, code: str):
|
||||
result = store.approve_code(platform, code)
|
||||
if result:
|
||||
uid = result["user_id"]
|
||||
name = result.get("user_name", "")
|
||||
name = result.get("user_name") or ""
|
||||
display = f"{name} ({uid})" if name else uid
|
||||
print(f"\n Approved! User {display} on {platform} can now use the bot~")
|
||||
print(" They'll be recognized automatically on their next message.\n")
|
||||
|
||||
+29
-9
@@ -512,10 +512,23 @@ class PluginManager:
|
||||
# Public
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def discover_and_load(self) -> None:
|
||||
"""Scan all plugin sources and load each plugin found."""
|
||||
if self._discovered:
|
||||
def discover_and_load(self, force: bool = False) -> None:
|
||||
"""Scan all plugin sources and load each plugin found.
|
||||
|
||||
When ``force`` is true, clear cached discovery state first so config
|
||||
changes or newly-added bundled backends become visible in long-lived
|
||||
sessions without requiring a full agent restart.
|
||||
"""
|
||||
if self._discovered and not force:
|
||||
return
|
||||
if force:
|
||||
self._plugins.clear()
|
||||
self._hooks.clear()
|
||||
self._plugin_tool_names.clear()
|
||||
self._cli_commands.clear()
|
||||
self._plugin_commands.clear()
|
||||
self._plugin_skills.clear()
|
||||
self._context_engine = None
|
||||
self._discovered = True
|
||||
|
||||
manifests: List[PluginManifest] = []
|
||||
@@ -1029,9 +1042,13 @@ def get_plugin_manager() -> PluginManager:
|
||||
return _plugin_manager
|
||||
|
||||
|
||||
def discover_plugins() -> None:
|
||||
"""Discover and load all plugins (idempotent)."""
|
||||
get_plugin_manager().discover_and_load()
|
||||
def discover_plugins(force: bool = False) -> None:
|
||||
"""Discover and load all plugins.
|
||||
|
||||
Default behavior is idempotent. Pass ``force=True`` to rescan plugin
|
||||
manifests and reload state in the current process.
|
||||
"""
|
||||
get_plugin_manager().discover_and_load(force=force)
|
||||
|
||||
|
||||
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||||
@@ -1082,10 +1099,13 @@ def get_pre_tool_call_block_message(
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_plugins_discovered() -> PluginManager:
|
||||
"""Return the global manager after running idempotent plugin discovery."""
|
||||
def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
|
||||
"""Return the global manager after ensuring plugin discovery has run.
|
||||
|
||||
Pass ``force=True`` to rescan in the current process.
|
||||
"""
|
||||
manager = get_plugin_manager()
|
||||
manager.discover_and_load()
|
||||
manager.discover_and_load(force=force)
|
||||
return manager
|
||||
|
||||
|
||||
|
||||
+41
-15
@@ -863,19 +863,15 @@ def _safe_extract_profile_archive(archive: Path, destination: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"""Import a profile from a tar.gz archive.
|
||||
def _inspect_profile_archive_roots(archive: Path) -> set[str]:
|
||||
"""Return the archive's top-level directory names.
|
||||
|
||||
If *name* is not given, infers it from the archive's top-level directory.
|
||||
Returns the imported profile directory.
|
||||
Profile imports expect exactly one root directory. Inspecting the archive
|
||||
before extraction lets us stage the import safely instead of mutating a
|
||||
live profile tree first and reconciling names later.
|
||||
"""
|
||||
import tarfile
|
||||
|
||||
archive = Path(archive_path)
|
||||
if not archive.exists():
|
||||
raise FileNotFoundError(f"Archive not found: {archive}")
|
||||
|
||||
# Peek at the archive to find the top-level directory name
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
top_dirs = {
|
||||
parts[0]
|
||||
@@ -889,13 +885,33 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
for member in tf.getmembers()
|
||||
if member.isdir()
|
||||
}
|
||||
return top_dirs
|
||||
|
||||
inferred_name = name or (top_dirs.pop() if len(top_dirs) == 1 else None)
|
||||
|
||||
def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
"""Import a profile from a tar.gz archive.
|
||||
|
||||
If *name* is not given, infers it from the archive's top-level directory.
|
||||
Returns the imported profile directory.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
archive = Path(archive_path)
|
||||
if not archive.exists():
|
||||
raise FileNotFoundError(f"Archive not found: {archive}")
|
||||
|
||||
top_dirs = _inspect_profile_archive_roots(archive)
|
||||
archive_root = top_dirs.pop() if len(top_dirs) == 1 else None
|
||||
inferred_name = name or archive_root
|
||||
if not inferred_name:
|
||||
raise ValueError(
|
||||
"Cannot determine profile name from archive. "
|
||||
"Specify it explicitly: hermes profile import <archive> --name <name>"
|
||||
)
|
||||
if archive_root is None:
|
||||
raise ValueError(
|
||||
"Profile archive must contain exactly one top-level directory."
|
||||
)
|
||||
|
||||
# Archives exported from the default profile have "default/" as top-level
|
||||
# dir. Importing as "default" would target ~/.hermes itself — disallow
|
||||
@@ -914,12 +930,22 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
profiles_root = _get_profiles_root()
|
||||
profiles_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_safe_extract_profile_archive(archive, profiles_root)
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_profile_import_") as tmpdir:
|
||||
staging_root = Path(tmpdir)
|
||||
_safe_extract_profile_archive(archive, staging_root)
|
||||
|
||||
# If the archive extracted under a different name, rename
|
||||
extracted = profiles_root / (top_dirs.pop() if top_dirs else inferred_name)
|
||||
if extracted != profile_dir and extracted.exists():
|
||||
extracted.rename(profile_dir)
|
||||
extracted = staging_root / archive_root
|
||||
if not extracted.is_dir():
|
||||
raise ValueError(
|
||||
f"Profile archive root is missing or invalid: {archive_root}"
|
||||
)
|
||||
|
||||
final_source = extracted
|
||||
if archive_root != inferred_name:
|
||||
final_source = staging_root / inferred_name
|
||||
extracted.rename(final_source)
|
||||
|
||||
shutil.move(str(final_source), str(profile_dir))
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
@@ -2334,6 +2334,7 @@ def setup_gateway(config: dict):
|
||||
launchd_install,
|
||||
launchd_start,
|
||||
launchd_restart,
|
||||
UserSystemdUnavailableError,
|
||||
)
|
||||
|
||||
service_installed = _is_service_installed()
|
||||
@@ -2357,6 +2358,10 @@ def setup_gateway(config: dict):
|
||||
systemd_restart()
|
||||
elif _is_macos:
|
||||
launchd_restart()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except Exception as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
@@ -2366,6 +2371,10 @@ def setup_gateway(config: dict):
|
||||
systemd_start()
|
||||
elif _is_macos:
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
elif supports_service_manager:
|
||||
@@ -2389,6 +2398,10 @@ def setup_gateway(config: dict):
|
||||
systemd_start(system=installed_scope == "system")
|
||||
elif _is_macos:
|
||||
launchd_start()
|
||||
except UserSystemdUnavailableError as e:
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
except Exception as e:
|
||||
|
||||
+85
-23
@@ -13,7 +13,7 @@ import json as _json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TypedDict
|
||||
|
||||
|
||||
from hermes_cli.config import (
|
||||
@@ -748,7 +748,7 @@ def _estimate_tool_tokens() -> Dict[str, int]:
|
||||
OpenAI-format tool schema. Triggers tool discovery on first call,
|
||||
then caches the result for the rest of the process.
|
||||
|
||||
Returns an empty dict when tiktoken or the registry is unavailable.
|
||||
Returns an empty dict when the registry is unavailable.
|
||||
"""
|
||||
global _tool_token_cache
|
||||
if _tool_token_cache is not None:
|
||||
@@ -756,11 +756,12 @@ def _estimate_tool_tokens() -> Dict[str, int]:
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
except Exception:
|
||||
logger.debug("tiktoken unavailable; skipping tool token estimation")
|
||||
_tool_token_cache = {}
|
||||
return _tool_token_cache
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"tiktoken is required for tool token estimation. "
|
||||
"Install with: pip install hermes-agent[cli]"
|
||||
) from None
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
|
||||
try:
|
||||
# Trigger full tool discovery (imports all tool modules).
|
||||
@@ -1019,6 +1020,11 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
|
||||
def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
"""Check if a provider entry matches the currently active config."""
|
||||
plugin_name = provider.get("image_gen_plugin_name")
|
||||
if plugin_name:
|
||||
image_cfg = config.get("image_gen", {})
|
||||
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
|
||||
|
||||
managed_feature = provider.get("managed_nous_feature")
|
||||
if managed_feature:
|
||||
features = get_nous_subscription_features(config)
|
||||
@@ -1026,6 +1032,13 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
if feature is None:
|
||||
return False
|
||||
if managed_feature == "image_gen":
|
||||
image_cfg = config.get("image_gen", {})
|
||||
if isinstance(image_cfg, dict):
|
||||
configured_provider = image_cfg.get("provider")
|
||||
if configured_provider not in (None, "", "fal"):
|
||||
return False
|
||||
if image_cfg.get("use_gateway") is False:
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if provider.get("tts_provider"):
|
||||
return (
|
||||
@@ -1048,6 +1061,16 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
if provider.get("web_backend"):
|
||||
current = config.get("web", {}).get("backend")
|
||||
return current == provider["web_backend"]
|
||||
if provider.get("imagegen_backend"):
|
||||
image_cfg = config.get("image_gen", {})
|
||||
if not isinstance(image_cfg, dict):
|
||||
return False
|
||||
configured_provider = image_cfg.get("provider")
|
||||
return (
|
||||
provider["imagegen_backend"] == "fal"
|
||||
and configured_provider in (None, "", "fal")
|
||||
and not image_cfg.get("use_gateway")
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -1076,13 +1099,19 @@ def _detect_active_provider_index(providers: list, config: dict) -> int:
|
||||
# right catalog at picker time.
|
||||
|
||||
|
||||
def _fal_model_catalog():
|
||||
class _ImagegenBackend(TypedDict):
|
||||
display: str
|
||||
config_key: str
|
||||
catalog_fn: Callable[[], Tuple[Dict[str, Dict[str, Any]], str]]
|
||||
|
||||
|
||||
def _fal_model_catalog() -> Tuple[Dict[str, Dict[str, Any]], str]:
|
||||
"""Lazy-load the FAL model catalog from the tool module."""
|
||||
from tools.image_generation_tool import FAL_MODELS, DEFAULT_MODEL
|
||||
return FAL_MODELS, DEFAULT_MODEL
|
||||
|
||||
|
||||
IMAGEGEN_BACKENDS = {
|
||||
IMAGEGEN_BACKENDS: Dict[str, _ImagegenBackend] = {
|
||||
"fal": {
|
||||
"display": "FAL.ai",
|
||||
"config_key": "image_gen",
|
||||
@@ -1245,6 +1274,18 @@ def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None
|
||||
_print_success(f" Model set to: {chosen}")
|
||||
|
||||
|
||||
def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
|
||||
"""Persist a plugin-backed image generation provider selection."""
|
||||
img_cfg = config.setdefault("image_gen", {})
|
||||
if not isinstance(img_cfg, dict):
|
||||
img_cfg = {}
|
||||
config["image_gen"] = img_cfg
|
||||
img_cfg["provider"] = plugin_name
|
||||
img_cfg["use_gateway"] = False
|
||||
_print_success(f" image_gen.provider set to: {plugin_name}")
|
||||
_configure_imagegen_model_for_plugin(plugin_name, config)
|
||||
|
||||
|
||||
def _configure_provider(provider: dict, config: dict):
|
||||
"""Configure a single provider - prompt for API keys and set config."""
|
||||
env_vars = provider.get("env_vars", [])
|
||||
@@ -1305,13 +1346,7 @@ def _configure_provider(provider: dict, config: dict):
|
||||
# and route model selection to the plugin's own catalog.
|
||||
plugin_name = provider.get("image_gen_plugin_name")
|
||||
if plugin_name:
|
||||
img_cfg = config.setdefault("image_gen", {})
|
||||
if not isinstance(img_cfg, dict):
|
||||
img_cfg = {}
|
||||
config["image_gen"] = img_cfg
|
||||
img_cfg["provider"] = plugin_name
|
||||
_print_success(f" image_gen.provider set to: {plugin_name}")
|
||||
_configure_imagegen_model_for_plugin(plugin_name, config)
|
||||
_select_plugin_image_gen_provider(plugin_name, config)
|
||||
return
|
||||
# Imagegen backends prompt for model selection after backend pick.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -1359,13 +1394,7 @@ def _configure_provider(provider: dict, config: dict):
|
||||
_print_success(f" {provider['name']} configured!")
|
||||
plugin_name = provider.get("image_gen_plugin_name")
|
||||
if plugin_name:
|
||||
img_cfg = config.setdefault("image_gen", {})
|
||||
if not isinstance(img_cfg, dict):
|
||||
img_cfg = {}
|
||||
config["image_gen"] = img_cfg
|
||||
img_cfg["provider"] = plugin_name
|
||||
_print_success(f" image_gen.provider set to: {plugin_name}")
|
||||
_configure_imagegen_model_for_plugin(plugin_name, config)
|
||||
_select_plugin_image_gen_provider(plugin_name, config)
|
||||
return
|
||||
# Imagegen backends prompt for model selection after env vars are in.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -1539,16 +1568,39 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
config.setdefault("web", {})["backend"] = provider["web_backend"]
|
||||
_print_success(f" Web backend set to: {provider['web_backend']}")
|
||||
|
||||
if managed_feature and managed_feature not in ("web", "tts", "browser"):
|
||||
section = config.setdefault(managed_feature, {})
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
config[managed_feature] = section
|
||||
section["use_gateway"] = True
|
||||
elif not managed_feature:
|
||||
for cat_key, cat in TOOL_CATEGORIES.items():
|
||||
if provider in cat.get("providers", []):
|
||||
section = config.get(cat_key)
|
||||
if isinstance(section, dict) and section.get("use_gateway"):
|
||||
section["use_gateway"] = False
|
||||
break
|
||||
|
||||
if not env_vars:
|
||||
if provider.get("post_setup"):
|
||||
_run_post_setup(provider["post_setup"])
|
||||
_print_success(f" {provider['name']} - no configuration needed!")
|
||||
if managed_feature:
|
||||
_print_info(" Requests for this tool will be billed to your Nous subscription.")
|
||||
plugin_name = provider.get("image_gen_plugin_name")
|
||||
if plugin_name:
|
||||
_select_plugin_image_gen_provider(plugin_name, config)
|
||||
return
|
||||
# Imagegen backends prompt for model selection on reconfig too.
|
||||
backend = provider.get("imagegen_backend")
|
||||
if backend:
|
||||
_configure_imagegen_model(backend, config)
|
||||
if backend == "fal":
|
||||
img_cfg = config.setdefault("image_gen", {})
|
||||
if isinstance(img_cfg, dict):
|
||||
img_cfg["provider"] = "fal"
|
||||
img_cfg["use_gateway"] = False
|
||||
return
|
||||
|
||||
for var in env_vars:
|
||||
@@ -1567,9 +1619,19 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
_print_info(" Kept current")
|
||||
|
||||
# Imagegen backends prompt for model selection on reconfig too.
|
||||
plugin_name = provider.get("image_gen_plugin_name")
|
||||
if plugin_name:
|
||||
_select_plugin_image_gen_provider(plugin_name, config)
|
||||
return
|
||||
|
||||
backend = provider.get("imagegen_backend")
|
||||
if backend:
|
||||
_configure_imagegen_model(backend, config)
|
||||
if backend == "fal":
|
||||
img_cfg = config.setdefault("image_gen", {})
|
||||
if isinstance(img_cfg, dict):
|
||||
img_cfg["provider"] = "fal"
|
||||
img_cfg["use_gateway"] = False
|
||||
|
||||
|
||||
def _reconfigure_simple_requirements(ts_key: str):
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ class _ComponentFilter(logging.Filter):
|
||||
# Used by _ComponentFilter and exposed for ``hermes logs --component``.
|
||||
COMPONENT_PREFIXES = {
|
||||
"gateway": ("gateway",),
|
||||
"agent": ("agent", "run_agent", "model_tools", "batch_runner"),
|
||||
"agent": ("agent", "run_agent", "model_tools", "scripts.batch_runner"),
|
||||
"tools": ("tools",),
|
||||
"cli": ("hermes_cli", "cli"),
|
||||
"cron": ("cron",),
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
hermes-agent = inputs.self.packages.${pkgs.system}.default;
|
||||
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
|
||||
# Deep-merge config type (from 0xrsydn/nix-hermes-agent)
|
||||
deepConfigType = lib.types.mkOptionType {
|
||||
@@ -777,7 +777,10 @@ HERMES_NIX_ENV_EOF
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = false;
|
||||
ReadWritePaths = [ cfg.stateDir ];
|
||||
ReadWritePaths = [
|
||||
cfg.stateDir
|
||||
cfg.workingDirectory
|
||||
];
|
||||
PrivateTmp = true;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""OpenAI image generation backend — ChatGPT/Codex OAuth variant.
|
||||
|
||||
Identical model catalog and tier semantics to the ``openai`` image-gen plugin
|
||||
(``gpt-image-2`` at low/medium/high quality), but routes the request through
|
||||
the Codex Responses API ``image_generation`` tool instead of the
|
||||
``images.generate`` REST endpoint. This lets users who are already
|
||||
authenticated with Codex/ChatGPT generate images without configuring a
|
||||
separate ``OPENAI_API_KEY``.
|
||||
|
||||
Selection precedence for the tier (first hit wins):
|
||||
|
||||
1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
|
||||
2. ``image_gen.openai-codex.model`` in ``config.yaml``
|
||||
3. ``image_gen.model`` in ``config.yaml`` (when it's one of our tier IDs)
|
||||
4. :data:`DEFAULT_MODEL` — ``gpt-image-2-medium``
|
||||
|
||||
Output is saved as PNG under ``$HERMES_HOME/cache/images/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model catalog — mirrors the ``openai`` plugin so the picker UX is identical.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
API_MODEL = "gpt-image-2"
|
||||
|
||||
_MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"gpt-image-2-low": {
|
||||
"display": "GPT Image 2 (Low)",
|
||||
"speed": "~15s",
|
||||
"strengths": "Fast iteration, lowest cost",
|
||||
"quality": "low",
|
||||
},
|
||||
"gpt-image-2-medium": {
|
||||
"display": "GPT Image 2 (Medium)",
|
||||
"speed": "~40s",
|
||||
"strengths": "Balanced — default",
|
||||
"quality": "medium",
|
||||
},
|
||||
"gpt-image-2-high": {
|
||||
"display": "GPT Image 2 (High)",
|
||||
"speed": "~2min",
|
||||
"strengths": "Highest fidelity, strongest prompt adherence",
|
||||
"quality": "high",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_MODEL = "gpt-image-2-medium"
|
||||
|
||||
_SIZES = {
|
||||
"landscape": "1536x1024",
|
||||
"square": "1024x1024",
|
||||
"portrait": "1024x1536",
|
||||
}
|
||||
|
||||
# Codex Responses surface used for the request. The chat model itself is only
|
||||
# the host that calls the ``image_generation`` tool; the actual image work is
|
||||
# done by ``API_MODEL``.
|
||||
_CODEX_CHAT_MODEL = "gpt-5.4"
|
||||
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
_CODEX_INSTRUCTIONS = (
|
||||
"You are an assistant that must fulfill image generation requests by "
|
||||
"using the image_generation tool when provided."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config + auth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_image_gen_config() -> Dict[str, Any]:
|
||||
"""Read ``image_gen`` from config.yaml (returns {} on any failure)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load image_gen config: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_model() -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decide which tier to use and return ``(model_id, meta)``."""
|
||||
import os
|
||||
|
||||
env_override = os.environ.get("OPENAI_IMAGE_MODEL")
|
||||
if env_override and env_override in _MODELS:
|
||||
return env_override, _MODELS[env_override]
|
||||
|
||||
cfg = _load_image_gen_config()
|
||||
sub = cfg.get("openai-codex") if isinstance(cfg.get("openai-codex"), dict) else {}
|
||||
candidate: Optional[str] = None
|
||||
if isinstance(sub, dict):
|
||||
value = sub.get("model")
|
||||
if isinstance(value, str) and value in _MODELS:
|
||||
candidate = value
|
||||
if candidate is None:
|
||||
top = cfg.get("model")
|
||||
if isinstance(top, str) and top in _MODELS:
|
||||
candidate = top
|
||||
|
||||
if candidate is not None:
|
||||
return candidate, _MODELS[candidate]
|
||||
|
||||
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
|
||||
|
||||
|
||||
def _read_codex_access_token() -> Optional[str]:
|
||||
"""Return a usable Codex OAuth token, or None.
|
||||
|
||||
Delegates to the canonical reader in ``agent.auxiliary_client`` so token
|
||||
expiry, credential pool selection, and JWT decoding stay in one place.
|
||||
"""
|
||||
try:
|
||||
from agent.auxiliary_client import _read_codex_access_token as _reader
|
||||
|
||||
token = _reader()
|
||||
if isinstance(token, str) and token.strip():
|
||||
return token.strip()
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve Codex access token: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _build_codex_client():
|
||||
"""Return an OpenAI client pointed at the ChatGPT/Codex backend, or None."""
|
||||
token = _read_codex_access_token()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
import openai
|
||||
from agent.auxiliary_client import _codex_cloudflare_headers
|
||||
|
||||
return openai.OpenAI(
|
||||
api_key=token,
|
||||
base_url=_CODEX_BASE_URL,
|
||||
default_headers=_codex_cloudflare_headers(token),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not build Codex image client: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> Optional[str]:
|
||||
"""Stream a Codex Responses image_generation call and return the b64 image."""
|
||||
image_b64: Optional[str] = None
|
||||
|
||||
with client.responses.stream(
|
||||
model=_CODEX_CHAT_MODEL,
|
||||
store=False,
|
||||
instructions=_CODEX_INSTRUCTIONS,
|
||||
input=[{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": prompt}],
|
||||
}],
|
||||
tools=[{
|
||||
"type": "image_generation",
|
||||
"model": API_MODEL,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"output_format": "png",
|
||||
"background": "opaque",
|
||||
"partial_images": 1,
|
||||
}],
|
||||
tool_choice={
|
||||
"type": "allowed_tools",
|
||||
"mode": "required",
|
||||
"tools": [{"type": "image_generation"}],
|
||||
},
|
||||
) as stream:
|
||||
for event in stream:
|
||||
event_type = getattr(event, "type", "")
|
||||
if event_type == "response.output_item.done":
|
||||
item = getattr(event, "item", None)
|
||||
if getattr(item, "type", None) == "image_generation_call":
|
||||
result = getattr(item, "result", None)
|
||||
if isinstance(result, str) and result:
|
||||
image_b64 = result
|
||||
elif event_type == "response.image_generation_call.partial_image":
|
||||
partial = getattr(event, "partial_image_b64", None)
|
||||
if isinstance(partial, str) and partial:
|
||||
image_b64 = partial
|
||||
final = stream.get_final_response()
|
||||
|
||||
# Final-response sweep covers the case where the stream finished before
|
||||
# we observed the ``output_item.done`` event for the image call.
|
||||
for item in getattr(final, "output", None) or []:
|
||||
if getattr(item, "type", None) == "image_generation_call":
|
||||
result = getattr(item, "result", None)
|
||||
if isinstance(result, str) and result:
|
||||
image_b64 = result
|
||||
|
||||
return image_b64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenAICodexImageGenProvider(ImageGenProvider):
|
||||
"""gpt-image-2 routed through ChatGPT/Codex OAuth instead of an API key."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "openai-codex"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "OpenAI (Codex auth)"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not _read_codex_access_token():
|
||||
return False
|
||||
try:
|
||||
import openai # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta["display"],
|
||||
"speed": meta["speed"],
|
||||
"strengths": meta["strengths"],
|
||||
"price": "varies",
|
||||
}
|
||||
for model_id, meta in _MODELS.items()
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
return DEFAULT_MODEL
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "OpenAI (Codex auth)",
|
||||
"badge": "free",
|
||||
"tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required",
|
||||
"env_vars": [],
|
||||
"post_setup_hint": (
|
||||
"Sign in with `hermes auth codex` (or `hermes setup` → Codex) "
|
||||
"if you haven't already. No API key needed."
|
||||
),
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
prompt = (prompt or "").strip()
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="Prompt is required and must be a non-empty string",
|
||||
error_type="invalid_argument",
|
||||
provider="openai-codex",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
if not _read_codex_access_token():
|
||||
return error_response(
|
||||
error=(
|
||||
"No Codex/ChatGPT OAuth credentials available. Run "
|
||||
"`hermes auth codex` (or `hermes setup` → Codex) to sign in."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="openai-codex",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
import openai # noqa: F401
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="openai Python package not installed (pip install openai)",
|
||||
error_type="missing_dependency",
|
||||
provider="openai-codex",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
tier_id, meta = _resolve_model()
|
||||
size = _SIZES.get(aspect, _SIZES["square"])
|
||||
|
||||
client = _build_codex_client()
|
||||
if client is None:
|
||||
return error_response(
|
||||
error="Could not initialize Codex image client",
|
||||
error_type="auth_required",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
b64 = _collect_image_b64(
|
||||
client,
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=meta["quality"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Codex image generation failed", exc_info=True)
|
||||
return error_response(
|
||||
error=f"OpenAI image generation via Codex auth failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
if not b64:
|
||||
return error_response(
|
||||
error="Codex response contained no image_generation_call result",
|
||||
error_type="empty_response",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
saved_path = save_b64_image(b64, prefix=f"openai_codex_{tier_id}")
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not save image to cache: {exc}",
|
||||
error_type="io_error",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
return success_response(
|
||||
image=str(saved_path),
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="openai-codex",
|
||||
extra={"size": size, "quality": meta["quality"]},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — register the Codex-backed image-gen provider."""
|
||||
ctx.register_image_gen_provider(OpenAICodexImageGenProvider())
|
||||
@@ -0,0 +1,5 @@
|
||||
name: openai-codex
|
||||
version: 1.0.0
|
||||
description: "OpenAI image generation backed by ChatGPT/Codex OAuth (gpt-image-2 via the Responses image_generation tool). Saves generated images to $HERMES_HOME/cache/images/."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
+33
-6
@@ -39,12 +39,12 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
modal = ["modal>=1.0.0,<2"]
|
||||
daytona = ["daytona>=0.148.0,<1"]
|
||||
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"]
|
||||
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
|
||||
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"]
|
||||
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8", "mutagen>=1.45,<2", "aiohttp-socks>=0.9,<1"]
|
||||
cron = ["croniter>=6.0.0,<7"]
|
||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"]
|
||||
cli = ["simple-term-menu>=1.0,<2"]
|
||||
cli = ["simple-term-menu>=1.0,<2", "tiktoken>=0.7,<1", "Pillow>=10,<12"]
|
||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||
voice = [
|
||||
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
|
||||
@@ -58,7 +58,7 @@ pty = [
|
||||
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
||||
]
|
||||
honcho = ["honcho-ai>=2.0.1,<3"]
|
||||
mcp = ["mcp>=1.2.0,<2"]
|
||||
mcp = ["mcp>=1.2.0,<2", "psutil>=5.9,<7"]
|
||||
homeassistant = ["aiohttp>=3.9.0,<4"]
|
||||
sms = ["aiohttp>=3.9.0,<4"]
|
||||
acp = ["agent-client-protocol>=0.9.0,<1.0"]
|
||||
@@ -85,7 +85,9 @@ rl = [
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
"wandb>=0.15.0,<1",
|
||||
"datasets>=2.14,<3",
|
||||
]
|
||||
tts-local = ["neutts[all]", "soundfile>=0.12,<1"]
|
||||
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"]
|
||||
all = [
|
||||
"hermes-agent[modal]",
|
||||
@@ -120,13 +122,13 @@ hermes-agent = "run_agent:main"
|
||||
hermes-acp = "acp_adapter.entry:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"]
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["web_dist/**/*"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "scripts"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -134,3 +136,28 @@ markers = [
|
||||
"integration: marks tests requiring external services (API keys, Modal, etc.)",
|
||||
]
|
||||
addopts = "-m 'not integration' -n auto"
|
||||
|
||||
[tool.ty.environment]
|
||||
python-version = "3.13"
|
||||
|
||||
[tool.ty.rules]
|
||||
unknown-argument = "warn"
|
||||
redundant-cast = "ignore"
|
||||
|
||||
[tool.ty.src]
|
||||
exclude = ["**"]
|
||||
|
||||
[[tool.ty.overrides]]
|
||||
include = ["**"]
|
||||
|
||||
[tool.ty.overrides.rules]
|
||||
unresolved-import = "ignore"
|
||||
invalid-method-override = "ignore"
|
||||
invalid-assignment = "ignore"
|
||||
not-iterable = "ignore"
|
||||
|
||||
[tool.ruff]
|
||||
exclude = ["*"]
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "7 days"
|
||||
|
||||
+176
-193
@@ -37,7 +37,10 @@ import time
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from typing import Callable, List, Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent.rate_limit_tracker import RateLimitState
|
||||
from openai import OpenAI
|
||||
import fire
|
||||
from datetime import datetime
|
||||
@@ -76,8 +79,6 @@ from tools.interrupt import set_interrupt as _set_interrupt
|
||||
from tools.browser_tool import cleanup_browser
|
||||
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
# Agent internals extracted to agent/ package for modularity
|
||||
from agent.memory_manager import build_memory_context_block, sanitize_context
|
||||
from agent.retry_utils import jittered_backoff
|
||||
@@ -98,19 +99,11 @@ from agent.model_metadata import (
|
||||
from agent.context_compressor import ContextCompressor
|
||||
from agent.subdirectory_hints import SubdirectoryHintTracker
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE
|
||||
from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from agent.codex_responses_adapter import (
|
||||
_chat_content_to_responses_parts,
|
||||
_chat_messages_to_responses_input as _codex_chat_messages_to_responses_input,
|
||||
_derive_responses_function_call_id as _codex_derive_responses_function_call_id,
|
||||
_deterministic_call_id as _codex_deterministic_call_id,
|
||||
_extract_responses_message_text as _codex_extract_responses_message_text,
|
||||
_extract_responses_reasoning_text as _codex_extract_responses_reasoning_text,
|
||||
_normalize_codex_response as _codex_normalize_codex_response,
|
||||
_preflight_codex_api_kwargs as _codex_preflight_codex_api_kwargs,
|
||||
_preflight_codex_input_items as _codex_preflight_codex_input_items,
|
||||
_responses_tools as _codex_responses_tools,
|
||||
_split_responses_tool_id as _codex_split_responses_tool_id,
|
||||
_summarize_user_message_for_log,
|
||||
)
|
||||
@@ -385,9 +378,8 @@ def _sanitize_surrogates(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
# _chat_content_to_responses_parts and _summarize_user_message_for_log are
|
||||
# imported from agent.codex_responses_adapter (see import block above).
|
||||
# They remain importable from run_agent for backward compatibility.
|
||||
# _summarize_user_message_for_log is imported from agent.codex_responses_adapter
|
||||
# (see import block above). Remains importable from run_agent for backward compat.
|
||||
|
||||
|
||||
def _sanitize_structure_surrogates(payload: Any) -> bool:
|
||||
@@ -733,17 +725,17 @@ class AIAgent:
|
||||
provider_require_parameters: bool = False,
|
||||
provider_data_collection: str = None,
|
||||
session_id: str = None,
|
||||
tool_progress_callback: callable = None,
|
||||
tool_start_callback: callable = None,
|
||||
tool_complete_callback: callable = None,
|
||||
thinking_callback: callable = None,
|
||||
reasoning_callback: callable = None,
|
||||
clarify_callback: callable = None,
|
||||
step_callback: callable = None,
|
||||
stream_delta_callback: callable = None,
|
||||
interim_assistant_callback: callable = None,
|
||||
tool_gen_callback: callable = None,
|
||||
status_callback: callable = None,
|
||||
tool_progress_callback: Callable[..., Any] = None,
|
||||
tool_start_callback: Callable[..., Any] = None,
|
||||
tool_complete_callback: Callable[..., Any] = None,
|
||||
thinking_callback: Callable[..., Any] = None,
|
||||
reasoning_callback: Callable[..., Any] = None,
|
||||
clarify_callback: Callable[..., Any] = None,
|
||||
step_callback: Callable[..., Any] = None,
|
||||
stream_delta_callback: Callable[..., Any] = None,
|
||||
interim_assistant_callback: Callable[..., Any] = None,
|
||||
tool_gen_callback: Callable[..., Any] = None,
|
||||
status_callback: Callable[..., Any] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
@@ -882,6 +874,13 @@ class AIAgent:
|
||||
else:
|
||||
self.api_mode = "chat_completions"
|
||||
|
||||
# Eagerly warm the transport cache so import errors surface at init,
|
||||
# not mid-conversation. Also validates the api_mode is registered.
|
||||
try:
|
||||
self._get_transport()
|
||||
except Exception:
|
||||
pass # Non-fatal — transport may not exist for all modes yet
|
||||
|
||||
try:
|
||||
from hermes_cli.model_normalize import (
|
||||
_AGGREGATOR_PROVIDERS,
|
||||
@@ -917,6 +916,10 @@ class AIAgent:
|
||||
)
|
||||
):
|
||||
self.api_mode = "codex_responses"
|
||||
# Invalidate the eager-warmed transport cache — api_mode changed
|
||||
# from chat_completions to codex_responses after the warm at __init__.
|
||||
if hasattr(self, "_transport_cache"):
|
||||
self._transport_cache.clear()
|
||||
|
||||
# Pre-warm OpenRouter model metadata cache in a background thread.
|
||||
# fetch_model_metadata() is cached for 1 hour; this avoids a blocking
|
||||
@@ -1048,7 +1051,7 @@ class AIAgent:
|
||||
for quiet_logger in [
|
||||
'tools', # all tools.* (terminal, browser, web, file, etc.)
|
||||
'run_agent', # agent runner internals
|
||||
'trajectory_compressor',
|
||||
'scripts.trajectory_compressor',
|
||||
'cron', # scheduler (only relevant in daemon mode)
|
||||
'hermes_cli', # CLI helpers
|
||||
]:
|
||||
@@ -1923,6 +1926,9 @@ class AIAgent:
|
||||
self.provider = new_provider
|
||||
self.base_url = base_url or self.base_url
|
||||
self.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(self, "_transport_cache"):
|
||||
self._transport_cache.clear()
|
||||
if api_key:
|
||||
self.api_key = api_key
|
||||
|
||||
@@ -2523,6 +2529,20 @@ class AIAgent:
|
||||
4. Tag variants: ``<think>``, ``<thinking>``, ``<reasoning>``,
|
||||
``<REASONING_SCRATCHPAD>``, ``<thought>`` (Gemma 4), all
|
||||
case-insensitive.
|
||||
|
||||
Additionally strips standalone tool-call XML blocks that some open
|
||||
models (notably Gemma variants on OpenRouter) emit inside assistant
|
||||
content instead of via the structured ``tool_calls`` field:
|
||||
* ``<tool_call>…</tool_call>``
|
||||
* ``<tool_calls>…</tool_calls>``
|
||||
* ``<tool_result>…</tool_result>``
|
||||
* ``<function_call>…</function_call>``
|
||||
* ``<function_calls>…</function_calls>``
|
||||
* ``<function name="…">…</function>`` (Gemma style)
|
||||
Ported from openclaw/openclaw#67318. The ``<function>`` variant is
|
||||
boundary-gated (only strips when the tag sits at start-of-line or
|
||||
after punctuation and carries a ``name="..."`` attribute) so prose
|
||||
mentions like "Use <function> in JavaScript" are preserved.
|
||||
"""
|
||||
if not content:
|
||||
return ""
|
||||
@@ -2534,6 +2554,30 @@ class AIAgent:
|
||||
content = re.sub(r'<reasoning>.*?</reasoning>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
content = re.sub(r'<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
content = re.sub(r'<thought>.*?</thought>', '', content, flags=re.DOTALL | re.IGNORECASE)
|
||||
# 1b. Tool-call XML blocks (openclaw/openclaw#67318). Handle the
|
||||
# generic tag names first — they have no attribute gating since
|
||||
# a literal <tool_call> in prose is already vanishingly rare.
|
||||
for _tc_name in ("tool_call", "tool_calls", "tool_result",
|
||||
"function_call", "function_calls"):
|
||||
content = re.sub(
|
||||
rf'<{_tc_name}\b[^>]*>.*?</{_tc_name}>',
|
||||
'',
|
||||
content,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
# 1c. <function name="...">...</function> — Gemma-style standalone
|
||||
# tool call. Only strip when the tag sits at a block boundary
|
||||
# (start of text, after a newline, or after sentence-ending
|
||||
# punctuation) AND carries a name="..." attribute. This keeps
|
||||
# prose mentions like "Use <function> to declare" safe.
|
||||
content = re.sub(
|
||||
r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*'
|
||||
r'<function\b[^>]*\bname\s*=[^>]*>'
|
||||
r'(?:(?:(?!</function>).)*)</function>',
|
||||
'',
|
||||
content,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
# 2. Unterminated reasoning block — open tag at a block boundary
|
||||
# (start of text, or after a newline) with no matching close.
|
||||
# Strip from the tag to end of string. Fixes #8878 / #9568
|
||||
@@ -2551,6 +2595,16 @@ class AIAgent:
|
||||
content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# 3b. Stray tool-call closers. (We do NOT strip bare <function> or
|
||||
# unterminated <function name="..."> because a truncated tail
|
||||
# during streaming may still be valuable to the user; matches
|
||||
# OpenClaw's intentional asymmetry.)
|
||||
content = re.sub(
|
||||
r'</(?:tool_call|tool_calls|tool_result|function_call|function_calls|function)>\s*',
|
||||
'',
|
||||
content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
@@ -4716,7 +4770,7 @@ class AIAgent:
|
||||
def _close_request_openai_client(self, client: Any, *, reason: str) -> None:
|
||||
self._close_openai_client(client, reason=reason, shared=False)
|
||||
|
||||
def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta: callable = None):
|
||||
def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta: Callable[..., Any] = None):
|
||||
"""Execute one streaming Responses API request and return the final response."""
|
||||
import httpx as _httpx
|
||||
|
||||
@@ -4844,7 +4898,7 @@ class AIAgent:
|
||||
active_client = client or self._ensure_primary_openai_client(reason="codex_create_stream_fallback")
|
||||
fallback_kwargs = dict(api_kwargs)
|
||||
fallback_kwargs["stream"] = True
|
||||
fallback_kwargs = self._get_codex_transport().preflight_kwargs(fallback_kwargs, allow_stream=True)
|
||||
fallback_kwargs = self._get_transport().preflight_kwargs(fallback_kwargs, allow_stream=True)
|
||||
stream_or_response = active_client.responses.create(**fallback_kwargs)
|
||||
|
||||
# Compatibility shim for mocks or providers that still return a concrete response.
|
||||
@@ -5199,6 +5253,9 @@ class AIAgent:
|
||||
result["response"] = self._anthropic_messages_create(api_kwargs)
|
||||
elif self.api_mode == "bedrock_converse":
|
||||
# Bedrock uses boto3 directly — no OpenAI client needed.
|
||||
# normalize_converse_response produces an OpenAI-compatible
|
||||
# SimpleNamespace so the rest of the agent loop can treat
|
||||
# bedrock responses like chat_completions responses.
|
||||
from agent.bedrock_adapter import (
|
||||
_get_bedrock_runtime_client,
|
||||
normalize_converse_response,
|
||||
@@ -5412,7 +5469,7 @@ class AIAgent:
|
||||
)
|
||||
|
||||
def _interruptible_streaming_api_call(
|
||||
self, api_kwargs: dict, *, on_first_delta: callable = None
|
||||
self, api_kwargs: dict, *, on_first_delta: Callable[..., Any] = None
|
||||
):
|
||||
"""Streaming variant of _interruptible_api_call for real-time token delivery.
|
||||
|
||||
@@ -6313,6 +6370,8 @@ class AIAgent:
|
||||
self.provider = fb_provider
|
||||
self.base_url = fb_base_url
|
||||
self.api_mode = fb_api_mode
|
||||
if hasattr(self, "_transport_cache"):
|
||||
self._transport_cache.clear()
|
||||
self._fallback_activated = True
|
||||
|
||||
# Honor per-provider / per-model request_timeout_seconds for the
|
||||
@@ -6424,6 +6483,8 @@ class AIAgent:
|
||||
self.provider = rt["provider"]
|
||||
self.base_url = rt["base_url"] # setter updates _base_url_lower
|
||||
self.api_mode = rt["api_mode"]
|
||||
if hasattr(self, "_transport_cache"):
|
||||
self._transport_cache.clear()
|
||||
self.api_key = rt["api_key"]
|
||||
self._client_kwargs = dict(rt["client_kwargs"])
|
||||
self._use_prompt_caching = rt["use_prompt_caching"]
|
||||
@@ -6530,6 +6591,8 @@ class AIAgent:
|
||||
self.provider = rt["provider"]
|
||||
self.base_url = rt["base_url"]
|
||||
self.api_mode = rt["api_mode"]
|
||||
if hasattr(self, "_transport_cache"):
|
||||
self._transport_cache.clear()
|
||||
self.api_key = rt["api_key"]
|
||||
|
||||
if self.api_mode == "anthropic_messages":
|
||||
@@ -6688,40 +6751,22 @@ class AIAgent:
|
||||
return suffix
|
||||
return "[A multimodal message was converted to text for Anthropic compatibility.]"
|
||||
|
||||
def _get_anthropic_transport(self):
|
||||
"""Return the cached AnthropicTransport instance (lazy singleton)."""
|
||||
t = getattr(self, "_anthropic_transport", None)
|
||||
if t is None:
|
||||
from agent.transports import get_transport
|
||||
t = get_transport("anthropic_messages")
|
||||
self._anthropic_transport = t
|
||||
return t
|
||||
def _get_transport(self, api_mode: str = None):
|
||||
"""Return the cached transport for the given (or current) api_mode.
|
||||
|
||||
def _get_codex_transport(self):
|
||||
"""Return the cached ResponsesApiTransport instance (lazy singleton)."""
|
||||
t = getattr(self, "_codex_transport", None)
|
||||
Lazy-initializes on first call per api_mode. Returns None if no
|
||||
transport is registered for the mode.
|
||||
"""
|
||||
mode = api_mode or self.api_mode
|
||||
cache = getattr(self, "_transport_cache", None)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
self._transport_cache = cache
|
||||
t = cache.get(mode)
|
||||
if t is None:
|
||||
from agent.transports import get_transport
|
||||
t = get_transport("codex_responses")
|
||||
self._codex_transport = t
|
||||
return t
|
||||
|
||||
def _get_chat_completions_transport(self):
|
||||
"""Return the cached ChatCompletionsTransport instance (lazy singleton)."""
|
||||
t = getattr(self, "_chat_completions_transport", None)
|
||||
if t is None:
|
||||
from agent.transports import get_transport
|
||||
t = get_transport("chat_completions")
|
||||
self._chat_completions_transport = t
|
||||
return t
|
||||
|
||||
def _get_bedrock_transport(self):
|
||||
"""Return the cached BedrockTransport instance (lazy singleton)."""
|
||||
t = getattr(self, "_bedrock_transport", None)
|
||||
if t is None:
|
||||
from agent.transports import get_transport
|
||||
t = get_transport("bedrock_converse")
|
||||
self._bedrock_transport = t
|
||||
t = get_transport(mode)
|
||||
cache[mode] = t
|
||||
return t
|
||||
|
||||
def _prepare_anthropic_messages_for_api(self, api_messages: list) -> list:
|
||||
@@ -6840,7 +6885,7 @@ class AIAgent:
|
||||
def _build_api_kwargs(self, api_messages: list) -> dict:
|
||||
"""Build the keyword arguments dict for the active API mode."""
|
||||
if self.api_mode == "anthropic_messages":
|
||||
_transport = self._get_anthropic_transport()
|
||||
_transport = self._get_transport()
|
||||
anthropic_messages = self._prepare_anthropic_messages_for_api(api_messages)
|
||||
ctx_len = getattr(self, "context_compressor", None)
|
||||
ctx_len = ctx_len.context_length if ctx_len else None
|
||||
@@ -6863,7 +6908,7 @@ class AIAgent:
|
||||
# AWS Bedrock native Converse API — bypasses the OpenAI client entirely.
|
||||
# The adapter handles message/tool conversion and boto3 calls directly.
|
||||
if self.api_mode == "bedrock_converse":
|
||||
_bt = self._get_bedrock_transport()
|
||||
_bt = self._get_transport()
|
||||
region = getattr(self, "_bedrock_region", None) or "us-east-1"
|
||||
guardrail = getattr(self, "_bedrock_guardrail_config", None)
|
||||
return _bt.build_kwargs(
|
||||
@@ -6876,7 +6921,7 @@ class AIAgent:
|
||||
)
|
||||
|
||||
if self.api_mode == "codex_responses":
|
||||
_ct = self._get_codex_transport()
|
||||
_ct = self._get_transport()
|
||||
is_github_responses = (
|
||||
base_url_host_matches(self.base_url, "models.github.ai")
|
||||
or base_url_host_matches(self.base_url, "api.githubcopilot.com")
|
||||
@@ -6904,7 +6949,7 @@ class AIAgent:
|
||||
)
|
||||
|
||||
# ── chat_completions (default) ─────────────────────────────────────
|
||||
_ct = self._get_chat_completions_transport()
|
||||
_ct = self._get_transport()
|
||||
|
||||
# Provider detection flags
|
||||
_is_qwen = self._is_qwen_portal()
|
||||
@@ -7363,12 +7408,15 @@ class AIAgent:
|
||||
_flush_temperature = _fixed_temp
|
||||
else:
|
||||
_flush_temperature = 0.3
|
||||
_flush_llm_kwargs: dict = {}
|
||||
if _flush_temperature is not None:
|
||||
_flush_llm_kwargs["temperature"] = _flush_temperature
|
||||
try:
|
||||
response = _call_llm(
|
||||
task="flush_memories",
|
||||
messages=api_messages,
|
||||
tools=[memory_tool_def],
|
||||
temperature=_flush_temperature,
|
||||
**_flush_llm_kwargs,
|
||||
max_tokens=5120,
|
||||
# timeout resolved from auxiliary.flush_memories.timeout config
|
||||
)
|
||||
@@ -7379,7 +7427,7 @@ class AIAgent:
|
||||
if not _aux_available and self.api_mode == "codex_responses":
|
||||
# No auxiliary client -- use the Codex Responses path directly
|
||||
codex_kwargs = self._build_api_kwargs(api_messages)
|
||||
codex_kwargs["tools"] = self._get_codex_transport().convert_tools([memory_tool_def])
|
||||
codex_kwargs["tools"] = self._get_transport().convert_tools([memory_tool_def])
|
||||
if _flush_temperature is not None:
|
||||
codex_kwargs["temperature"] = _flush_temperature
|
||||
else:
|
||||
@@ -7389,7 +7437,7 @@ class AIAgent:
|
||||
response = self._run_codex_stream(codex_kwargs)
|
||||
elif not _aux_available and self.api_mode == "anthropic_messages":
|
||||
# Native Anthropic — use the transport for kwargs
|
||||
_tflush = self._get_anthropic_transport()
|
||||
_tflush = self._get_transport()
|
||||
ant_kwargs = _tflush.build_kwargs(
|
||||
model=self.model, messages=api_messages,
|
||||
tools=[memory_tool_def], max_tokens=5120,
|
||||
@@ -7414,7 +7462,7 @@ class AIAgent:
|
||||
# Extract tool calls from the response, handling all API formats
|
||||
tool_calls = []
|
||||
if self.api_mode == "codex_responses" and not _aux_available:
|
||||
_ct_flush = self._get_codex_transport()
|
||||
_ct_flush = self._get_transport()
|
||||
_cnr_flush = _ct_flush.normalize_response(response)
|
||||
if _cnr_flush and _cnr_flush.tool_calls:
|
||||
tool_calls = [
|
||||
@@ -7424,19 +7472,26 @@ class AIAgent:
|
||||
) for tc in _cnr_flush.tool_calls
|
||||
]
|
||||
elif self.api_mode == "anthropic_messages" and not _aux_available:
|
||||
_tfn = self._get_anthropic_transport()
|
||||
_flush_nr = _tfn.normalize_response(response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
if _flush_nr and _flush_nr.tool_calls:
|
||||
_tfn = self._get_transport()
|
||||
_flush_result = _tfn.normalize_response(response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
if _flush_result and _flush_result.tool_calls:
|
||||
tool_calls = [
|
||||
SimpleNamespace(
|
||||
id=tc.id, type="function",
|
||||
function=SimpleNamespace(name=tc.name, arguments=tc.arguments),
|
||||
) for tc in _flush_nr.tool_calls
|
||||
) for tc in _flush_result.tool_calls
|
||||
]
|
||||
elif hasattr(response, "choices") and response.choices:
|
||||
assistant_message = response.choices[0].message
|
||||
if assistant_message.tool_calls:
|
||||
tool_calls = assistant_message.tool_calls
|
||||
elif self.api_mode in ("chat_completions", "bedrock_converse"):
|
||||
# chat_completions / bedrock — normalize through transport
|
||||
_flush_result = self._get_transport().normalize_response(response)
|
||||
if _flush_result.tool_calls:
|
||||
tool_calls = _flush_result.tool_calls
|
||||
elif _aux_available and hasattr(response, "choices") and response.choices:
|
||||
# Auxiliary client returned OpenAI-shaped response while main
|
||||
# api_mode is codex/anthropic — extract tool_calls from .choices
|
||||
_aux_msg = response.choices[0].message
|
||||
if hasattr(_aux_msg, "tool_calls") and _aux_msg.tool_calls:
|
||||
tool_calls = _aux_msg.tool_calls
|
||||
|
||||
for tc in tool_calls:
|
||||
if tc.function.name == "memory":
|
||||
@@ -8466,7 +8521,7 @@ class AIAgent:
|
||||
codex_kwargs = self._build_api_kwargs(api_messages)
|
||||
codex_kwargs.pop("tools", None)
|
||||
summary_response = self._run_codex_stream(codex_kwargs)
|
||||
_ct_sum = self._get_codex_transport()
|
||||
_ct_sum = self._get_transport()
|
||||
_cnr_sum = _ct_sum.normalize_response(summary_response)
|
||||
final_response = (_cnr_sum.content or "").strip()
|
||||
else:
|
||||
@@ -8496,21 +8551,18 @@ class AIAgent:
|
||||
summary_kwargs["extra_body"] = summary_extra_body
|
||||
|
||||
if self.api_mode == "anthropic_messages":
|
||||
_tsum = self._get_anthropic_transport()
|
||||
_tsum = self._get_transport()
|
||||
_ant_kw = _tsum.build_kwargs(model=self.model, messages=api_messages, tools=None,
|
||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config,
|
||||
is_oauth=self._is_anthropic_oauth,
|
||||
preserve_dots=self._anthropic_preserve_dots())
|
||||
summary_response = self._anthropic_messages_create(_ant_kw)
|
||||
_sum_nr = _tsum.normalize_response(summary_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
final_response = (_sum_nr.content or "").strip()
|
||||
_summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
final_response = (_summary_result.content or "").strip()
|
||||
else:
|
||||
summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs)
|
||||
|
||||
if summary_response.choices and summary_response.choices[0].message.content:
|
||||
final_response = summary_response.choices[0].message.content
|
||||
else:
|
||||
final_response = ""
|
||||
_summary_result = self._get_transport().normalize_response(summary_response)
|
||||
final_response = (_summary_result.content or "").strip()
|
||||
|
||||
if final_response:
|
||||
if "<think>" in final_response:
|
||||
@@ -8525,18 +8577,18 @@ class AIAgent:
|
||||
codex_kwargs = self._build_api_kwargs(api_messages)
|
||||
codex_kwargs.pop("tools", None)
|
||||
retry_response = self._run_codex_stream(codex_kwargs)
|
||||
_ct_retry = self._get_codex_transport()
|
||||
_ct_retry = self._get_transport()
|
||||
_cnr_retry = _ct_retry.normalize_response(retry_response)
|
||||
final_response = (_cnr_retry.content or "").strip()
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
_tretry = self._get_anthropic_transport()
|
||||
_tretry = self._get_transport()
|
||||
_ant_kw2 = _tretry.build_kwargs(model=self.model, messages=api_messages, tools=None,
|
||||
is_oauth=self._is_anthropic_oauth,
|
||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config,
|
||||
preserve_dots=self._anthropic_preserve_dots())
|
||||
retry_response = self._anthropic_messages_create(_ant_kw2)
|
||||
_retry_nr = _tretry.normalize_response(retry_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
final_response = (_retry_nr.content or "").strip()
|
||||
_retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
final_response = (_retry_result.content or "").strip()
|
||||
else:
|
||||
summary_kwargs = {
|
||||
"model": self.model,
|
||||
@@ -8550,11 +8602,8 @@ class AIAgent:
|
||||
summary_kwargs["extra_body"] = summary_extra_body
|
||||
|
||||
summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary_retry").chat.completions.create(**summary_kwargs)
|
||||
|
||||
if summary_response.choices and summary_response.choices[0].message.content:
|
||||
final_response = summary_response.choices[0].message.content
|
||||
else:
|
||||
final_response = ""
|
||||
_retry_result = self._get_transport().normalize_response(summary_response)
|
||||
final_response = (_retry_result.content or "").strip()
|
||||
|
||||
if final_response:
|
||||
if "<think>" in final_response:
|
||||
@@ -8576,9 +8625,9 @@ class AIAgent:
|
||||
self,
|
||||
user_message: str,
|
||||
system_message: str = None,
|
||||
conversation_history: List[Dict[str, Any]] = None,
|
||||
conversation_history: List[Dict[str, Any]] | None = None,
|
||||
task_id: str = None,
|
||||
stream_callback: Optional[callable] = None,
|
||||
stream_callback: Optional[Callable[..., Any]] = None,
|
||||
persist_user_message: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -9285,7 +9334,7 @@ class AIAgent:
|
||||
if self._force_ascii_payload:
|
||||
_sanitize_structure_non_ascii(api_kwargs)
|
||||
if self.api_mode == "codex_responses":
|
||||
api_kwargs = self._get_codex_transport().preflight_kwargs(api_kwargs, allow_stream=False)
|
||||
api_kwargs = self._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
@@ -9373,7 +9422,7 @@ class AIAgent:
|
||||
response_invalid = False
|
||||
error_details = []
|
||||
if self.api_mode == "codex_responses":
|
||||
_ct_v = self._get_codex_transport()
|
||||
_ct_v = self._get_transport()
|
||||
if not _ct_v.validate_response(response):
|
||||
if response is None:
|
||||
response_invalid = True
|
||||
@@ -9402,7 +9451,7 @@ class AIAgent:
|
||||
response_invalid = True
|
||||
error_details.append("response.output is empty")
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
_tv = self._get_anthropic_transport()
|
||||
_tv = self._get_transport()
|
||||
if not _tv.validate_response(response):
|
||||
response_invalid = True
|
||||
if response is None:
|
||||
@@ -9410,7 +9459,7 @@ class AIAgent:
|
||||
else:
|
||||
error_details.append("response.content invalid (not a non-empty list)")
|
||||
elif self.api_mode == "bedrock_converse":
|
||||
_btv = self._get_bedrock_transport()
|
||||
_btv = self._get_transport()
|
||||
if not _btv.validate_response(response):
|
||||
response_invalid = True
|
||||
if response is None:
|
||||
@@ -9418,7 +9467,7 @@ class AIAgent:
|
||||
else:
|
||||
error_details.append("Bedrock response invalid (no output or choices)")
|
||||
else:
|
||||
_ctv = self._get_chat_completions_transport()
|
||||
_ctv = self._get_transport()
|
||||
if not _ctv.validate_response(response):
|
||||
response_invalid = True
|
||||
if response is None:
|
||||
@@ -9578,15 +9627,18 @@ class AIAgent:
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
_tfr = self._get_anthropic_transport()
|
||||
_tfr = self._get_transport()
|
||||
finish_reason = _tfr.map_finish_reason(response.stop_reason)
|
||||
elif self.api_mode == "bedrock_converse":
|
||||
# Bedrock response is already normalized at dispatch — finish_reason
|
||||
# is already in OpenAI format via normalize_converse_response()
|
||||
finish_reason = response.choices[0].finish_reason if hasattr(response, "choices") and response.choices else "stop"
|
||||
# Bedrock response already normalized at dispatch — use transport
|
||||
_bt_fr = self._get_transport()
|
||||
_bedrock_result = _bt_fr.normalize_response(response)
|
||||
finish_reason = _bedrock_result.finish_reason
|
||||
else:
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
assistant_message = response.choices[0].message
|
||||
_cc_fr = self._get_transport()
|
||||
_finish_result = _cc_fr.normalize_response(response)
|
||||
finish_reason = _finish_result.finish_reason
|
||||
assistant_message = _finish_result
|
||||
if self._should_treat_stop_as_truncated(
|
||||
finish_reason,
|
||||
assistant_message,
|
||||
@@ -9609,27 +9661,14 @@ class AIAgent:
|
||||
# interim assistant message is byte-identical to what
|
||||
# would have been appended in the non-truncated path.
|
||||
_trunc_msg = None
|
||||
if self.api_mode in ("chat_completions", "bedrock_converse"):
|
||||
_trunc_msg = response.choices[0].message if (hasattr(response, "choices") and response.choices) else None
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
_trunc_nr = self._get_anthropic_transport().normalize_response(
|
||||
_trunc_transport = self._get_transport()
|
||||
if self.api_mode == "anthropic_messages":
|
||||
_trunc_result = _trunc_transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_anthropic_oauth
|
||||
)
|
||||
_trunc_msg = SimpleNamespace(
|
||||
content=_trunc_nr.content,
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
id=tc.id, type="function",
|
||||
function=SimpleNamespace(name=tc.name, arguments=tc.arguments),
|
||||
) for tc in (_trunc_nr.tool_calls or [])
|
||||
] or None,
|
||||
reasoning=_trunc_nr.reasoning,
|
||||
reasoning_content=None,
|
||||
reasoning_details=(
|
||||
_trunc_nr.provider_data.get("reasoning_details")
|
||||
if _trunc_nr.provider_data else None
|
||||
),
|
||||
)
|
||||
else:
|
||||
_trunc_result = _trunc_transport.normalize_response(response)
|
||||
_trunc_msg = _trunc_result
|
||||
|
||||
_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
|
||||
@@ -10192,7 +10231,7 @@ class AIAgent:
|
||||
auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)"
|
||||
print(f"{self.log_prefix}🔐 Anthropic 401 — authentication failed.")
|
||||
print(f"{self.log_prefix} Auth method: {auth_method}")
|
||||
print(f"{self.log_prefix} Token prefix: {key[:12]}..." if key and len(key) > 12 else f"{self.log_prefix} Token: (empty or short)")
|
||||
print(f"{self.log_prefix} Token prefix: {str(key)[:12]}..." if key and len(str(key)) > 12 else f"{self.log_prefix} Token: (empty or short)")
|
||||
print(f"{self.log_prefix} Troubleshooting:")
|
||||
from hermes_constants import display_hermes_home as _dhh_fn
|
||||
_dhh = _dhh_fn()
|
||||
@@ -10860,69 +10899,13 @@ class AIAgent:
|
||||
break
|
||||
|
||||
try:
|
||||
if self.api_mode == "codex_responses":
|
||||
_ct = self._get_codex_transport()
|
||||
_cnr = _ct.normalize_response(response)
|
||||
# Back-compat shim: downstream expects SimpleNamespace with
|
||||
# codex-specific fields (.codex_reasoning_items, .reasoning_details,
|
||||
# and .call_id/.response_item_id on tool calls).
|
||||
_tc_list = None
|
||||
if _cnr.tool_calls:
|
||||
_tc_list = []
|
||||
for tc in _cnr.tool_calls:
|
||||
_tc_ns = SimpleNamespace(
|
||||
id=tc.id, type="function",
|
||||
function=SimpleNamespace(name=tc.name, arguments=tc.arguments),
|
||||
)
|
||||
if tc.provider_data:
|
||||
if tc.provider_data.get("call_id"):
|
||||
_tc_ns.call_id = tc.provider_data["call_id"]
|
||||
if tc.provider_data.get("response_item_id"):
|
||||
_tc_ns.response_item_id = tc.provider_data["response_item_id"]
|
||||
_tc_list.append(_tc_ns)
|
||||
assistant_message = SimpleNamespace(
|
||||
content=_cnr.content,
|
||||
tool_calls=_tc_list or None,
|
||||
reasoning=_cnr.reasoning,
|
||||
reasoning_content=None,
|
||||
codex_reasoning_items=(
|
||||
_cnr.provider_data.get("codex_reasoning_items")
|
||||
if _cnr.provider_data else None
|
||||
),
|
||||
reasoning_details=(
|
||||
_cnr.provider_data.get("reasoning_details")
|
||||
if _cnr.provider_data else None
|
||||
),
|
||||
)
|
||||
finish_reason = _cnr.finish_reason
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
_transport = self._get_anthropic_transport()
|
||||
_nr = _transport.normalize_response(
|
||||
response, strip_tool_prefix=self._is_anthropic_oauth
|
||||
)
|
||||
# Back-compat shim: downstream code expects SimpleNamespace with
|
||||
# .content, .tool_calls, .reasoning, .reasoning_content,
|
||||
# .reasoning_details attributes.
|
||||
assistant_message = SimpleNamespace(
|
||||
content=_nr.content,
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
id=tc.id,
|
||||
type="function",
|
||||
function=SimpleNamespace(name=tc.name, arguments=tc.arguments),
|
||||
)
|
||||
for tc in (_nr.tool_calls or [])
|
||||
] or None,
|
||||
reasoning=_nr.reasoning,
|
||||
reasoning_content=None,
|
||||
reasoning_details=(
|
||||
_nr.provider_data.get("reasoning_details")
|
||||
if _nr.provider_data else None
|
||||
),
|
||||
)
|
||||
finish_reason = _nr.finish_reason
|
||||
else:
|
||||
assistant_message = response.choices[0].message
|
||||
_transport = self._get_transport()
|
||||
_normalize_kwargs = {}
|
||||
if self.api_mode == "anthropic_messages":
|
||||
_normalize_kwargs["strip_tool_prefix"] = self._is_anthropic_oauth
|
||||
normalized = _transport.normalize_response(response, **_normalize_kwargs)
|
||||
assistant_message = normalized
|
||||
finish_reason = normalized.finish_reason
|
||||
|
||||
# Normalize content to string — some OpenAI-compatible servers
|
||||
# (llama-server, etc.) return content as a dict or list instead
|
||||
@@ -11592,7 +11575,7 @@ class AIAgent:
|
||||
messages.append(assistant_msg)
|
||||
|
||||
if reasoning_text:
|
||||
reasoning_preview = reasoning_text[:500] + "..." if len(reasoning_text) > 500 else reasoning_text
|
||||
reasoning_preview = str(reasoning_text)[:500] + "..." if len(str(reasoning_text)) > 500 else reasoning_text
|
||||
logger.warning(
|
||||
"Reasoning-only response (no visible content) "
|
||||
"after exhausting retries and fallback. "
|
||||
@@ -11931,7 +11914,7 @@ class AIAgent:
|
||||
|
||||
return result
|
||||
|
||||
def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:
|
||||
def chat(self, message: str, stream_callback: Optional[Callable[..., Any]] = None) -> str:
|
||||
"""
|
||||
Simple chat interface that returns just the final response.
|
||||
|
||||
|
||||
@@ -20,9 +20,13 @@ Usage:
|
||||
python batch_runner.py --dataset_file=data.jsonl --batch_size=10 --run_name=my_run --distribution=image_gen
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
@@ -1126,7 +1130,7 @@ def main(
|
||||
num_workers: int = 4,
|
||||
resume: bool = False,
|
||||
verbose: bool = False,
|
||||
list_distributions: bool = False,
|
||||
show_distributions: bool = False,
|
||||
ephemeral_system_prompt: str = None,
|
||||
log_prefix_chars: int = 100,
|
||||
providers_allowed: str = None,
|
||||
@@ -1154,7 +1158,7 @@ def main(
|
||||
num_workers (int): Number of parallel worker processes (default: 4)
|
||||
resume (bool): Resume from checkpoint if run was interrupted (default: False)
|
||||
verbose (bool): Enable verbose logging (default: False)
|
||||
list_distributions (bool): List available toolset distributions and exit
|
||||
show_distributions (bool): List available toolset distributions and exit
|
||||
ephemeral_system_prompt (str): System prompt used during agent execution but NOT saved to trajectories (optional)
|
||||
log_prefix_chars (int): Number of characters to show in log previews for tool calls/responses (default: 20)
|
||||
providers_allowed (str): Comma-separated list of OpenRouter providers to allow (e.g. "anthropic,openai")
|
||||
@@ -1186,10 +1190,10 @@ def main(
|
||||
--prefill_messages_file=configs/prefill_opus.json
|
||||
|
||||
# List available distributions
|
||||
python batch_runner.py --list_distributions
|
||||
python batch_runner.py --show_distributions
|
||||
"""
|
||||
# Handle list distributions
|
||||
if list_distributions:
|
||||
if show_distributions:
|
||||
from toolset_distributions import print_distribution_info
|
||||
|
||||
print("📊 Available Toolset Distributions")
|
||||
@@ -265,7 +265,7 @@ def check_config(groq_key, eleven_key):
|
||||
if voice_mode_path.exists():
|
||||
try:
|
||||
import json
|
||||
modes = json.loads(voice_mode_path.read_text())
|
||||
modes = json.loads(voice_mode_path.read_text(encoding="utf-8"))
|
||||
off_count = sum(1 for v in modes.values() if v == "off")
|
||||
all_count = sum(1 for v in modes.values() if v == "all")
|
||||
check("Voice mode state", True, f"{all_count} on, {off_count} off, {len(modes)} total")
|
||||
|
||||
@@ -26,10 +26,13 @@ Usage:
|
||||
python mini_swe_runner.py --prompts_file prompts.jsonl --output_file trajectories.jsonl --env docker
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
+64
-1
@@ -26,6 +26,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -43,7 +44,9 @@ AUTHOR_MAP = {
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"teknium@nousresearch.com": "teknium1",
|
||||
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
||||
"343873859@qq.com": "DrStrangerUJN",
|
||||
# contributors (from noreply pattern)
|
||||
"david.vv@icloud.com": "davidvv",
|
||||
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
|
||||
"snreynolds2506@gmail.com": "snreynolds",
|
||||
"35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
|
||||
@@ -98,6 +101,7 @@ AUTHOR_MAP = {
|
||||
"30841158+n-WN@users.noreply.github.com": "n-WN",
|
||||
"tsuijinglei@gmail.com": "hiddenpuppy",
|
||||
"jerome@clawwork.ai": "HiddenPuppy",
|
||||
"wysie@users.noreply.github.com": "Wysie",
|
||||
"leoyuan0099@gmail.com": "keyuyuan",
|
||||
"bxzt2006@163.com": "Only-Code-A",
|
||||
"i@troy-y.org": "TroyMitchell911",
|
||||
@@ -106,6 +110,7 @@ AUTHOR_MAP = {
|
||||
"134848055+UNLINEARITY@users.noreply.github.com": "UNLINEARITY",
|
||||
"ben.burtenshaw@gmail.com": "burtenshaw",
|
||||
"roopaknijhara@gmail.com": "rnijhara",
|
||||
"josephzcan@gmail.com": "j0sephz",
|
||||
# contributors (manual mapping from git names)
|
||||
"ahmedsherif95@gmail.com": "asheriif",
|
||||
"liujinkun@bytedance.com": "liujinkun2025",
|
||||
@@ -141,6 +146,7 @@ AUTHOR_MAP = {
|
||||
"331214+counterposition@users.noreply.github.com": "counterposition",
|
||||
"blspear@gmail.com": "BrennerSpear",
|
||||
"akhater@gmail.com": "akhater",
|
||||
"Cos_Admin@PTG-COS.lodluvup4uaudnm3ycd14giyug.xx.internal.cloudapp.net": "akhater",
|
||||
"239876380+handsdiff@users.noreply.github.com": "handsdiff",
|
||||
"hesapacicam112@gmail.com": "etherman-os",
|
||||
"mark.ramsell@rivermounts.com": "mark-ramsell",
|
||||
@@ -345,6 +351,63 @@ AUTHOR_MAP = {
|
||||
"105142614+VTRiot@users.noreply.github.com": "VTRiot",
|
||||
"vivien000812@gmail.com": "iamagenius00",
|
||||
"89228157+Feranmi10@users.noreply.github.com": "Feranmi10",
|
||||
"simon@gtcl.us": "simon-gtcl",
|
||||
"suzukaze.haduki@gmail.com": "houko",
|
||||
"cliff@cigii.com": "cgarwood82",
|
||||
"anna@oa.ke": "anna-oake",
|
||||
"jaffarkeikei@gmail.com": "jaffarkeikei",
|
||||
"hxp@hxp.plus": "hxp-plus",
|
||||
"3580442280@qq.com": "Tianworld",
|
||||
"wujianxu91@gmail.com": "wujhsu",
|
||||
"zhrh120@gmail.com": "niyoh120",
|
||||
"vrinek@hey.com": "vrinek",
|
||||
"268198004+xandersbell@users.noreply.github.com": "xandersbell",
|
||||
"somme4096@gmail.com": "Somme4096",
|
||||
"brian@tiuxo.com": "brianclemens",
|
||||
"25944632+yudaiyan@users.noreply.github.com": "yudaiyan",
|
||||
"chayton@sina.com": "ycbai",
|
||||
"longsizhuo@gmail.com": "longsizhuo",
|
||||
"chenb19870707@gmail.com": "ms-alan",
|
||||
"276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123",
|
||||
"22549957+li0near@users.noreply.github.com": "li0near",
|
||||
"23434080+sicnuyudidi@users.noreply.github.com": "sicnuyudidi",
|
||||
"haimu0x0@proton.me": "haimu0x",
|
||||
"abdelmajidnidnasser1@gmail.com": "NIDNASSER-Abdelmajid",
|
||||
"projectadmin@wit.id": "projectadmin-dev",
|
||||
"mrigankamondal10@gmail.com": "Dev-Mriganka",
|
||||
"132275809+shushuzn@users.noreply.github.com": "shushuzn",
|
||||
"ibrahimozsarac@gmail.com": "iborazzi",
|
||||
"130149563+A-afflatus@users.noreply.github.com": "A-afflatus",
|
||||
"huangkwell@163.com": "huangke19",
|
||||
"tanishq@exa.ai": "10ishq",
|
||||
"363708+christopherwoodall@users.noreply.github.com": "christopherwoodall",
|
||||
"zhang9w0v5@qq.com": "zhang9w0v5",
|
||||
"fuleinist@outlook.com": "fuleinist",
|
||||
"43494187+Llugaes@users.noreply.github.com": "Llugaes",
|
||||
"fengtianyu88@users.noreply.github.com": "fengtianyu88",
|
||||
"l.moncany@gmail.com": "lmoncany",
|
||||
"fatinghenji@users.noreply.github.com": "fatinghenji",
|
||||
"xin.peng.dr@gmail.com": "xinpengdr",
|
||||
"mike@mikewaters.net": "mikewaters",
|
||||
"65117428+WadydX@users.noreply.github.com": "WadydX",
|
||||
"216480837+isaachuangGMICLOUD@users.noreply.github.com": "isaachuangGMICLOUD",
|
||||
"nukuom976228@gmail.com": "hsy5571616",
|
||||
"11462216+Nan93@users.noreply.github.com": "Nan93",
|
||||
"l973401489@126.com": "zhouxiaoya12",
|
||||
"373119611@qq.com": "roytian1217",
|
||||
"brett@brettbrewer.com": "minorgod",
|
||||
"67779267+wenhao7@users.noreply.github.com": "wenhao7",
|
||||
"git@yzx9.xyz": "yzx9",
|
||||
"nilesh@cloudgeni.us": "lvnilesh",
|
||||
"63502660+azhengbot@users.noreply.github.com": "azhengbot",
|
||||
"sharvil.saxena@gmail.com": "sharziki",
|
||||
"yuanhe@minimaxi.com": "RyanLee-Dev",
|
||||
"curtis992250@gmail.com": "TaroballzChen",
|
||||
"92638503+Lind3ey@users.noreply.github.com": "Lind3ey",
|
||||
"1352808998@qq.com": "phpoh",
|
||||
"caliberoviv@gmail.com": "vivganes",
|
||||
"michaelfackerell@gmail.com": "MikeFac",
|
||||
"18024642@qq.com": "GuyCui",
|
||||
}
|
||||
|
||||
|
||||
@@ -623,7 +686,7 @@ def get_commits(since_tag=None):
|
||||
return commits
|
||||
|
||||
|
||||
def get_pr_number(subject: str) -> str:
|
||||
def get_pr_number(subject: str) -> Optional[str]:
|
||||
"""Extract PR number from commit subject if present."""
|
||||
match = re.search(r"#(\d+)", subject)
|
||||
if match:
|
||||
|
||||
@@ -19,18 +19,23 @@ Environment Variables:
|
||||
OPENROUTER_API_KEY: API key for OpenRouter (required for agent)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import fire
|
||||
import yaml
|
||||
|
||||
from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
_hermes_home = get_hermes_home()
|
||||
_project_env = Path(__file__).parent / '.env'
|
||||
_project_env = Path(__file__).parent.parent / '.env'
|
||||
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
@@ -60,8 +65,6 @@ from tools.rl_training_tool import get_missing_keys
|
||||
# Config Loading
|
||||
# ============================================================================
|
||||
|
||||
from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
|
||||
|
||||
DEFAULT_MODEL = "anthropic/claude-opus-4.5"
|
||||
DEFAULT_BASE_URL = OPENROUTER_BASE_URL
|
||||
|
||||
@@ -267,7 +267,7 @@ def run_compression(input_dir: Path, output_dir: Path, config_path: str):
|
||||
# Import the compressor
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from trajectory_compressor import TrajectoryCompressor, CompressionConfig
|
||||
from scripts.trajectory_compressor import TrajectoryCompressor, CompressionConfig
|
||||
|
||||
print(f"\n🗜️ Running trajectory compression...")
|
||||
print(f" Input: {input_dir}")
|
||||
|
||||
@@ -30,14 +30,18 @@ Usage:
|
||||
python trajectory_compressor.py --input=data/my_run --sample_percent=10
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import json
|
||||
import time
|
||||
import yaml
|
||||
import logging
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Tuple, Callable
|
||||
from typing import List, Dict, Any, Optional, Tuple, Callable, cast
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
@@ -52,7 +56,7 @@ from agent.retry_utils import jittered_backoff
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
_hermes_home = get_hermes_home()
|
||||
_project_env = Path(__file__).parent / ".env"
|
||||
_project_env = Path(__file__).parent.parent / ".env"
|
||||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
||||
|
||||
|
||||
@@ -75,7 +79,7 @@ def _effective_temperature_for_model(
|
||||
if fixed_temperature is OMIT_TEMPERATURE:
|
||||
return None # caller must omit temperature
|
||||
if fixed_temperature is not None:
|
||||
return fixed_temperature
|
||||
return cast(float, fixed_temperature)
|
||||
return requested_temperature
|
||||
|
||||
|
||||
@@ -607,11 +611,14 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
|
||||
|
||||
if getattr(self, '_use_call_llm', False):
|
||||
from agent.auxiliary_client import call_llm
|
||||
_call_llm_kwargs: dict = {}
|
||||
if summary_temperature is not None:
|
||||
_call_llm_kwargs["temperature"] = summary_temperature
|
||||
response = call_llm(
|
||||
provider=self._llm_provider,
|
||||
model=self.config.summarization_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=summary_temperature,
|
||||
**_call_llm_kwargs,
|
||||
max_tokens=self.config.summary_target_tokens * 2,
|
||||
)
|
||||
else:
|
||||
@@ -623,20 +630,21 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
|
||||
if summary_temperature is not None:
|
||||
_create_kwargs["temperature"] = summary_temperature
|
||||
response = self.client.chat.completions.create(**_create_kwargs)
|
||||
|
||||
|
||||
summary = self._coerce_summary_content(response.choices[0].message.content)
|
||||
return self._ensure_summary_prefix(summary)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
metrics.summarization_errors += 1
|
||||
self.logger.warning(f"Summarization attempt {attempt + 1} failed: {e}")
|
||||
|
||||
|
||||
if attempt < self.config.max_retries - 1:
|
||||
time.sleep(jittered_backoff(attempt + 1, base_delay=self.config.retry_delay, max_delay=30.0))
|
||||
else:
|
||||
# Fallback: create a basic summary
|
||||
return "[CONTEXT SUMMARY]: [Summary generation failed - previous turns contained tool calls and responses that have been compressed to save context space.]"
|
||||
|
||||
raise AssertionError("unreachable: retry loop exhausted")
|
||||
|
||||
async def _generate_summary_async(self, content: str, metrics: TrajectoryMetrics) -> str:
|
||||
"""
|
||||
Generate a summary of the compressed turns using OpenRouter (async version).
|
||||
@@ -676,11 +684,14 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
|
||||
|
||||
if getattr(self, '_use_call_llm', False):
|
||||
from agent.auxiliary_client import async_call_llm
|
||||
_async_llm_kwargs: dict = {}
|
||||
if summary_temperature is not None:
|
||||
_async_llm_kwargs["temperature"] = summary_temperature
|
||||
response = await async_call_llm(
|
||||
provider=self._llm_provider,
|
||||
model=self.config.summarization_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=summary_temperature,
|
||||
**_async_llm_kwargs,
|
||||
max_tokens=self.config.summary_target_tokens * 2,
|
||||
)
|
||||
else:
|
||||
@@ -692,20 +703,21 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
|
||||
if summary_temperature is not None:
|
||||
_create_kwargs["temperature"] = summary_temperature
|
||||
response = await self._get_async_client().chat.completions.create(**_create_kwargs)
|
||||
|
||||
|
||||
summary = self._coerce_summary_content(response.choices[0].message.content)
|
||||
return self._ensure_summary_prefix(summary)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
metrics.summarization_errors += 1
|
||||
self.logger.warning(f"Summarization attempt {attempt + 1} failed: {e}")
|
||||
|
||||
|
||||
if attempt < self.config.max_retries - 1:
|
||||
await asyncio.sleep(jittered_backoff(attempt + 1, base_delay=self.config.retry_delay, max_delay=30.0))
|
||||
else:
|
||||
# Fallback: create a basic summary
|
||||
return "[CONTEXT SUMMARY]: [Summary generation failed - previous turns contained tool calls and responses that have been compressed to save context space.]"
|
||||
|
||||
raise AssertionError("unreachable: retry loop exhausted")
|
||||
|
||||
def compress_trajectory(
|
||||
self,
|
||||
trajectory: List[Dict[str, str]]
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"name": "hermes-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#fix/abprops-abt-fetch",
|
||||
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770",
|
||||
"express": "^4.21.0",
|
||||
"pino": "^9.0.0",
|
||||
"qrcode-terminal": "^0.12.0"
|
||||
|
||||
@@ -8,7 +8,7 @@ metadata:
|
||||
hermes:
|
||||
tags: [wiki, knowledge-base, research, notes, markdown, rag-alternative]
|
||||
category: research
|
||||
related_skills: [obsidian, arxiv, agentic-research-ideas]
|
||||
related_skills: [obsidian, arxiv]
|
||||
---
|
||||
|
||||
# Karpathy's LLM Wiki
|
||||
|
||||
@@ -18,12 +18,12 @@ from agent.anthropic_adapter import (
|
||||
convert_messages_to_anthropic,
|
||||
convert_tools_to_anthropic,
|
||||
is_claude_code_token_valid,
|
||||
normalize_anthropic_response,
|
||||
normalize_model_name,
|
||||
read_claude_code_credentials,
|
||||
resolve_anthropic_token,
|
||||
run_oauth_setup_token,
|
||||
)
|
||||
from agent.transports import get_transport
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1242,10 +1242,10 @@ class TestNormalizeResponse:
|
||||
|
||||
def test_text_response(self):
|
||||
block = SimpleNamespace(type="text", text="Hello world")
|
||||
msg, reason = normalize_anthropic_response(self._make_response([block]))
|
||||
assert msg.content == "Hello world"
|
||||
assert reason == "stop"
|
||||
assert msg.tool_calls is None
|
||||
nr = get_transport("anthropic_messages").normalize_response(self._make_response([block]))
|
||||
assert nr.content == "Hello world"
|
||||
assert nr.finish_reason == "stop"
|
||||
assert nr.tool_calls is None
|
||||
|
||||
def test_tool_use_response(self):
|
||||
blocks = [
|
||||
@@ -1257,24 +1257,24 @@ class TestNormalizeResponse:
|
||||
input={"query": "test"},
|
||||
),
|
||||
]
|
||||
msg, reason = normalize_anthropic_response(
|
||||
nr = get_transport("anthropic_messages").normalize_response(
|
||||
self._make_response(blocks, "tool_use")
|
||||
)
|
||||
assert msg.content == "Searching..."
|
||||
assert reason == "tool_calls"
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert msg.tool_calls[0].function.name == "search"
|
||||
assert json.loads(msg.tool_calls[0].function.arguments) == {"query": "test"}
|
||||
assert nr.content == "Searching..."
|
||||
assert nr.finish_reason == "tool_calls"
|
||||
assert len(nr.tool_calls) == 1
|
||||
assert nr.tool_calls[0].name == "search"
|
||||
assert json.loads(nr.tool_calls[0].arguments) == {"query": "test"}
|
||||
|
||||
def test_thinking_response(self):
|
||||
blocks = [
|
||||
SimpleNamespace(type="thinking", thinking="Let me reason about this..."),
|
||||
SimpleNamespace(type="text", text="The answer is 42."),
|
||||
]
|
||||
msg, reason = normalize_anthropic_response(self._make_response(blocks))
|
||||
assert msg.content == "The answer is 42."
|
||||
assert msg.reasoning == "Let me reason about this..."
|
||||
assert msg.reasoning_details == [{"type": "thinking", "thinking": "Let me reason about this..."}]
|
||||
nr = get_transport("anthropic_messages").normalize_response(self._make_response(blocks))
|
||||
assert nr.content == "The answer is 42."
|
||||
assert nr.reasoning == "Let me reason about this..."
|
||||
assert nr.provider_data["reasoning_details"] == [{"type": "thinking", "thinking": "Let me reason about this..."}]
|
||||
|
||||
def test_thinking_response_preserves_signature(self):
|
||||
blocks = [
|
||||
@@ -1285,24 +1285,24 @@ class TestNormalizeResponse:
|
||||
redacted=False,
|
||||
),
|
||||
]
|
||||
msg, _ = normalize_anthropic_response(self._make_response(blocks))
|
||||
assert msg.reasoning_details[0]["signature"] == "opaque_signature"
|
||||
assert msg.reasoning_details[0]["thinking"] == "Let me reason about this..."
|
||||
nr = get_transport("anthropic_messages").normalize_response(self._make_response(blocks))
|
||||
assert nr.provider_data["reasoning_details"][0]["signature"] == "opaque_signature"
|
||||
assert nr.provider_data["reasoning_details"][0]["thinking"] == "Let me reason about this..."
|
||||
|
||||
def test_stop_reason_mapping(self):
|
||||
block = SimpleNamespace(type="text", text="x")
|
||||
_, r1 = normalize_anthropic_response(
|
||||
nr1 = get_transport("anthropic_messages").normalize_response(
|
||||
self._make_response([block], "end_turn")
|
||||
)
|
||||
_, r2 = normalize_anthropic_response(
|
||||
nr2 = get_transport("anthropic_messages").normalize_response(
|
||||
self._make_response([block], "tool_use")
|
||||
)
|
||||
_, r3 = normalize_anthropic_response(
|
||||
nr3 = get_transport("anthropic_messages").normalize_response(
|
||||
self._make_response([block], "max_tokens")
|
||||
)
|
||||
assert r1 == "stop"
|
||||
assert r2 == "tool_calls"
|
||||
assert r3 == "length"
|
||||
assert nr1.finish_reason == "stop"
|
||||
assert nr2.finish_reason == "tool_calls"
|
||||
assert nr3.finish_reason == "length"
|
||||
|
||||
def test_stop_reason_refusal_and_context_exceeded(self):
|
||||
# Claude 4.5+ introduced two new stop_reason values the Messages API
|
||||
@@ -1310,24 +1310,24 @@ class TestNormalizeResponse:
|
||||
# handlers already understand, instead of silently collapsing to
|
||||
# "stop" (old behavior).
|
||||
block = SimpleNamespace(type="text", text="")
|
||||
_, refusal_reason = normalize_anthropic_response(
|
||||
nr_refusal = get_transport("anthropic_messages").normalize_response(
|
||||
self._make_response([block], "refusal")
|
||||
)
|
||||
_, overflow_reason = normalize_anthropic_response(
|
||||
nr_overflow = get_transport("anthropic_messages").normalize_response(
|
||||
self._make_response([block], "model_context_window_exceeded")
|
||||
)
|
||||
assert refusal_reason == "content_filter"
|
||||
assert overflow_reason == "length"
|
||||
assert nr_refusal.finish_reason == "content_filter"
|
||||
assert nr_overflow.finish_reason == "length"
|
||||
|
||||
def test_no_text_content(self):
|
||||
block = SimpleNamespace(
|
||||
type="tool_use", id="tc_1", name="search", input={"q": "hi"}
|
||||
)
|
||||
msg, reason = normalize_anthropic_response(
|
||||
nr = get_transport("anthropic_messages").normalize_response(
|
||||
self._make_response([block], "tool_use")
|
||||
)
|
||||
assert msg.content is None
|
||||
assert len(msg.tool_calls) == 1
|
||||
assert nr.content is None
|
||||
assert len(nr.tool_calls) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1659,3 +1659,91 @@ class TestToolChoice:
|
||||
tool_choice="search",
|
||||
)
|
||||
assert kwargs["tool_choice"] == {"type": "tool", "name": "search"}
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# max_tokens resolver — openclaw/openclaw#66664 port
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from agent.anthropic_adapter import (
|
||||
_resolve_positive_anthropic_max_tokens,
|
||||
_resolve_anthropic_messages_max_tokens,
|
||||
)
|
||||
|
||||
|
||||
class TestResolvePositiveMaxTokens:
|
||||
"""Unit tests for the positive-int resolver helper."""
|
||||
|
||||
def test_positive_int_passes_through(self):
|
||||
assert _resolve_positive_anthropic_max_tokens(8192) == 8192
|
||||
|
||||
def test_zero_returns_none(self):
|
||||
assert _resolve_positive_anthropic_max_tokens(0) is None
|
||||
|
||||
def test_negative_int_returns_none(self):
|
||||
assert _resolve_positive_anthropic_max_tokens(-1) is None
|
||||
assert _resolve_positive_anthropic_max_tokens(-500) is None
|
||||
|
||||
def test_fractional_float_floored_and_kept_if_positive(self):
|
||||
# 8192.7 -> 8192, still positive
|
||||
assert _resolve_positive_anthropic_max_tokens(8192.7) == 8192
|
||||
|
||||
def test_small_positive_float_below_one_returns_none(self):
|
||||
# 0.5 floors to 0, which is not positive
|
||||
assert _resolve_positive_anthropic_max_tokens(0.5) is None
|
||||
|
||||
def test_negative_float_returns_none(self):
|
||||
assert _resolve_positive_anthropic_max_tokens(-1.5) is None
|
||||
|
||||
def test_nan_returns_none(self):
|
||||
assert _resolve_positive_anthropic_max_tokens(float("nan")) is None
|
||||
|
||||
def test_infinity_returns_none(self):
|
||||
assert _resolve_positive_anthropic_max_tokens(float("inf")) is None
|
||||
assert _resolve_positive_anthropic_max_tokens(float("-inf")) is None
|
||||
|
||||
def test_bool_true_returns_none(self):
|
||||
# True is an int subclass but semantically never a real max_tokens value
|
||||
assert _resolve_positive_anthropic_max_tokens(True) is None
|
||||
assert _resolve_positive_anthropic_max_tokens(False) is None
|
||||
|
||||
def test_string_returns_none(self):
|
||||
assert _resolve_positive_anthropic_max_tokens("8192") is None
|
||||
|
||||
def test_none_returns_none(self):
|
||||
assert _resolve_positive_anthropic_max_tokens(None) is None
|
||||
|
||||
|
||||
class TestResolveMessagesMaxTokens:
|
||||
"""Integration tests for the full Messages resolver."""
|
||||
|
||||
def test_positive_requested_wins(self):
|
||||
assert _resolve_anthropic_messages_max_tokens(
|
||||
8192, "claude-opus-4-6"
|
||||
) == 8192
|
||||
|
||||
def test_zero_falls_back_to_model_default(self):
|
||||
# Should use _get_anthropic_max_output(model), not crash
|
||||
result = _resolve_anthropic_messages_max_tokens(0, "claude-opus-4-6")
|
||||
assert result > 0
|
||||
|
||||
def test_none_falls_back_to_model_default(self):
|
||||
result = _resolve_anthropic_messages_max_tokens(None, "claude-opus-4-6")
|
||||
assert result > 0
|
||||
|
||||
def test_negative_falls_back_to_model_default(self):
|
||||
# Previously leaked -1 to the API; now falls back safely
|
||||
result = _resolve_anthropic_messages_max_tokens(-1, "claude-opus-4-6")
|
||||
assert result > 0
|
||||
|
||||
def test_fractional_positive_floored(self):
|
||||
assert _resolve_anthropic_messages_max_tokens(
|
||||
8192.5, "claude-opus-4-6"
|
||||
) == 8192
|
||||
|
||||
def test_sub_one_float_falls_back(self):
|
||||
# 0.5 floors to 0 -> not positive -> falls back to model ceiling
|
||||
result = _resolve_anthropic_messages_max_tokens(0.5, "claude-opus-4-6")
|
||||
assert result > 0
|
||||
assert result != 0
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Regression tests: normalize_anthropic_response_v2 vs v1.
|
||||
|
||||
Constructs mock Anthropic responses and asserts that the v2 function
|
||||
(returning NormalizedResponse) produces identical field values to the
|
||||
original v1 function (returning SimpleNamespace + finish_reason).
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent.anthropic_adapter import (
|
||||
normalize_anthropic_response,
|
||||
normalize_anthropic_response_v2,
|
||||
)
|
||||
from agent.transports.types import NormalizedResponse, ToolCall
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build mock Anthropic SDK responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _text_block(text: str):
|
||||
return SimpleNamespace(type="text", text=text)
|
||||
|
||||
|
||||
def _thinking_block(thinking: str, signature: str = "sig_abc"):
|
||||
return SimpleNamespace(type="thinking", thinking=thinking, signature=signature)
|
||||
|
||||
|
||||
def _tool_use_block(id: str, name: str, input: dict):
|
||||
return SimpleNamespace(type="tool_use", id=id, name=name, input=input)
|
||||
|
||||
|
||||
def _response(content_blocks, stop_reason="end_turn"):
|
||||
return SimpleNamespace(
|
||||
content=content_blocks,
|
||||
stop_reason=stop_reason,
|
||||
usage=SimpleNamespace(
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTextOnly:
|
||||
"""Text-only response — no tools, no thinking."""
|
||||
|
||||
def setup_method(self):
|
||||
self.resp = _response([_text_block("Hello world")])
|
||||
self.v1_msg, self.v1_finish = normalize_anthropic_response(self.resp)
|
||||
self.v2 = normalize_anthropic_response_v2(self.resp)
|
||||
|
||||
def test_type(self):
|
||||
assert isinstance(self.v2, NormalizedResponse)
|
||||
|
||||
def test_content_matches(self):
|
||||
assert self.v2.content == self.v1_msg.content
|
||||
|
||||
def test_finish_reason_matches(self):
|
||||
assert self.v2.finish_reason == self.v1_finish
|
||||
|
||||
def test_no_tool_calls(self):
|
||||
assert self.v2.tool_calls is None
|
||||
assert self.v1_msg.tool_calls is None
|
||||
|
||||
def test_no_reasoning(self):
|
||||
assert self.v2.reasoning is None
|
||||
assert self.v1_msg.reasoning is None
|
||||
|
||||
|
||||
class TestWithToolCalls:
|
||||
"""Response with tool calls."""
|
||||
|
||||
def setup_method(self):
|
||||
self.resp = _response(
|
||||
[
|
||||
_text_block("I'll check that"),
|
||||
_tool_use_block("toolu_abc", "terminal", {"command": "ls"}),
|
||||
_tool_use_block("toolu_def", "read_file", {"path": "/tmp"}),
|
||||
],
|
||||
stop_reason="tool_use",
|
||||
)
|
||||
self.v1_msg, self.v1_finish = normalize_anthropic_response(self.resp)
|
||||
self.v2 = normalize_anthropic_response_v2(self.resp)
|
||||
|
||||
def test_finish_reason(self):
|
||||
assert self.v2.finish_reason == "tool_calls"
|
||||
assert self.v1_finish == "tool_calls"
|
||||
|
||||
def test_tool_call_count(self):
|
||||
assert len(self.v2.tool_calls) == 2
|
||||
assert len(self.v1_msg.tool_calls) == 2
|
||||
|
||||
def test_tool_call_ids_match(self):
|
||||
for i in range(2):
|
||||
assert self.v2.tool_calls[i].id == self.v1_msg.tool_calls[i].id
|
||||
|
||||
def test_tool_call_names_match(self):
|
||||
assert self.v2.tool_calls[0].name == "terminal"
|
||||
assert self.v2.tool_calls[1].name == "read_file"
|
||||
for i in range(2):
|
||||
assert self.v2.tool_calls[i].name == self.v1_msg.tool_calls[i].function.name
|
||||
|
||||
def test_tool_call_arguments_match(self):
|
||||
for i in range(2):
|
||||
assert self.v2.tool_calls[i].arguments == self.v1_msg.tool_calls[i].function.arguments
|
||||
|
||||
def test_content_preserved(self):
|
||||
assert self.v2.content == self.v1_msg.content
|
||||
assert "check that" in self.v2.content
|
||||
|
||||
|
||||
class TestWithThinking:
|
||||
"""Response with thinking blocks (Claude 3.5+ extended thinking)."""
|
||||
|
||||
def setup_method(self):
|
||||
self.resp = _response([
|
||||
_thinking_block("Let me think about this carefully..."),
|
||||
_text_block("The answer is 42."),
|
||||
])
|
||||
self.v1_msg, self.v1_finish = normalize_anthropic_response(self.resp)
|
||||
self.v2 = normalize_anthropic_response_v2(self.resp)
|
||||
|
||||
def test_reasoning_matches(self):
|
||||
assert self.v2.reasoning == self.v1_msg.reasoning
|
||||
assert "think about this" in self.v2.reasoning
|
||||
|
||||
def test_reasoning_details_in_provider_data(self):
|
||||
v1_details = self.v1_msg.reasoning_details
|
||||
v2_details = self.v2.provider_data.get("reasoning_details") if self.v2.provider_data else None
|
||||
assert v1_details is not None
|
||||
assert v2_details is not None
|
||||
assert len(v2_details) == len(v1_details)
|
||||
|
||||
def test_content_excludes_thinking(self):
|
||||
assert self.v2.content == "The answer is 42."
|
||||
|
||||
|
||||
class TestMixed:
|
||||
"""Response with thinking + text + tool calls."""
|
||||
|
||||
def setup_method(self):
|
||||
self.resp = _response(
|
||||
[
|
||||
_thinking_block("Planning my approach..."),
|
||||
_text_block("I'll run the command"),
|
||||
_tool_use_block("toolu_xyz", "terminal", {"command": "pwd"}),
|
||||
],
|
||||
stop_reason="tool_use",
|
||||
)
|
||||
self.v1_msg, self.v1_finish = normalize_anthropic_response(self.resp)
|
||||
self.v2 = normalize_anthropic_response_v2(self.resp)
|
||||
|
||||
def test_all_fields_present(self):
|
||||
assert self.v2.content is not None
|
||||
assert self.v2.tool_calls is not None
|
||||
assert self.v2.reasoning is not None
|
||||
assert self.v2.finish_reason == "tool_calls"
|
||||
|
||||
def test_content_matches(self):
|
||||
assert self.v2.content == self.v1_msg.content
|
||||
|
||||
def test_reasoning_matches(self):
|
||||
assert self.v2.reasoning == self.v1_msg.reasoning
|
||||
|
||||
def test_tool_call_matches(self):
|
||||
assert self.v2.tool_calls[0].id == self.v1_msg.tool_calls[0].id
|
||||
assert self.v2.tool_calls[0].name == self.v1_msg.tool_calls[0].function.name
|
||||
|
||||
|
||||
class TestStopReasons:
|
||||
"""Verify finish_reason mapping matches between v1 and v2."""
|
||||
|
||||
@pytest.mark.parametrize("stop_reason,expected", [
|
||||
("end_turn", "stop"),
|
||||
("tool_use", "tool_calls"),
|
||||
("max_tokens", "length"),
|
||||
("stop_sequence", "stop"),
|
||||
("refusal", "content_filter"),
|
||||
("model_context_window_exceeded", "length"),
|
||||
("unknown_future_reason", "stop"),
|
||||
])
|
||||
def test_stop_reason_mapping(self, stop_reason, expected):
|
||||
resp = _response([_text_block("x")], stop_reason=stop_reason)
|
||||
v1_msg, v1_finish = normalize_anthropic_response(resp)
|
||||
v2 = normalize_anthropic_response_v2(resp)
|
||||
assert v2.finish_reason == v1_finish == expected
|
||||
|
||||
|
||||
class TestStripToolPrefix:
|
||||
"""Verify mcp_ prefix stripping works identically."""
|
||||
|
||||
def test_prefix_stripped(self):
|
||||
resp = _response(
|
||||
[_tool_use_block("toolu_1", "mcp_terminal", {"cmd": "ls"})],
|
||||
stop_reason="tool_use",
|
||||
)
|
||||
v1_msg, _ = normalize_anthropic_response(resp, strip_tool_prefix=True)
|
||||
v2 = normalize_anthropic_response_v2(resp, strip_tool_prefix=True)
|
||||
assert v1_msg.tool_calls[0].function.name == "terminal"
|
||||
assert v2.tool_calls[0].name == "terminal"
|
||||
|
||||
def test_prefix_kept(self):
|
||||
resp = _response(
|
||||
[_tool_use_block("toolu_1", "mcp_terminal", {"cmd": "ls"})],
|
||||
stop_reason="tool_use",
|
||||
)
|
||||
v1_msg, _ = normalize_anthropic_response(resp, strip_tool_prefix=False)
|
||||
v2 = normalize_anthropic_response_v2(resp, strip_tool_prefix=False)
|
||||
assert v1_msg.tool_calls[0].function.name == "mcp_terminal"
|
||||
assert v2.tool_calls[0].name == "mcp_terminal"
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Edge cases: empty content, no blocks, etc."""
|
||||
|
||||
def test_empty_content_blocks(self):
|
||||
resp = _response([])
|
||||
v1_msg, v1_finish = normalize_anthropic_response(resp)
|
||||
v2 = normalize_anthropic_response_v2(resp)
|
||||
assert v2.content == v1_msg.content
|
||||
assert v2.content is None
|
||||
|
||||
def test_no_reasoning_details_means_none_provider_data(self):
|
||||
resp = _response([_text_block("hi")])
|
||||
v2 = normalize_anthropic_response_v2(resp)
|
||||
assert v2.provider_data is None
|
||||
|
||||
def test_v2_returns_dataclass_not_namespace(self):
|
||||
resp = _response([_text_block("hi")])
|
||||
v2 = normalize_anthropic_response_v2(resp)
|
||||
assert isinstance(v2, NormalizedResponse)
|
||||
assert not isinstance(v2, SimpleNamespace)
|
||||
@@ -1162,3 +1162,75 @@ def test_load_pool_does_not_seed_qwen_oauth_when_no_token(tmp_path, monkeypatch)
|
||||
|
||||
assert not pool.has_credentials()
|
||||
assert pool.entries() == []
|
||||
|
||||
|
||||
def _build_pool_with_entries(tmp_path, monkeypatch, provider="openrouter", entries=None):
|
||||
"""Helper: build a CredentialPool directly without seeding side-effects."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr("agent.credential_pool._seed_from_singletons", lambda p, e: (False, set()))
|
||||
monkeypatch.setattr("agent.credential_pool._seed_from_env", lambda p, e: (False, set()))
|
||||
if entries is None:
|
||||
entries = [
|
||||
{
|
||||
"id": "cred-1",
|
||||
"label": "primary",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "tok-1",
|
||||
},
|
||||
{
|
||||
"id": "cred-2",
|
||||
"label": "secondary",
|
||||
"auth_type": "api_key",
|
||||
"priority": 1,
|
||||
"source": "manual",
|
||||
"access_token": "tok-2",
|
||||
},
|
||||
]
|
||||
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {provider: entries}})
|
||||
from agent.credential_pool import load_pool
|
||||
return load_pool(provider)
|
||||
|
||||
|
||||
def test_remove_entry_removes_by_id(tmp_path, monkeypatch):
|
||||
"""remove_entry should remove the entry with matching id and return it."""
|
||||
pool = _build_pool_with_entries(tmp_path, monkeypatch)
|
||||
|
||||
removed = pool.remove_entry("cred-1")
|
||||
|
||||
assert removed is not None
|
||||
assert removed.id == "cred-1"
|
||||
remaining_ids = [e.id for e in pool.entries()]
|
||||
assert "cred-1" not in remaining_ids
|
||||
assert "cred-2" in remaining_ids
|
||||
|
||||
|
||||
def test_remove_entry_returns_none_for_unknown_id(tmp_path, monkeypatch):
|
||||
"""remove_entry returns None when no entry matches the given id."""
|
||||
pool = _build_pool_with_entries(tmp_path, monkeypatch)
|
||||
|
||||
result = pool.remove_entry("nonexistent-id")
|
||||
|
||||
assert result is None
|
||||
# Pool should still have both original entries
|
||||
assert len(pool.entries()) == 2
|
||||
|
||||
|
||||
def test_remove_entry_renumbers_priorities(tmp_path, monkeypatch):
|
||||
"""After remove_entry, remaining entries receive sequential priorities 0, 1, ..."""
|
||||
pool = _build_pool_with_entries(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
entries=[
|
||||
{"id": "cred-1", "label": "a", "auth_type": "api_key", "priority": 0, "source": "manual", "access_token": "tok-1"},
|
||||
{"id": "cred-2", "label": "b", "auth_type": "api_key", "priority": 1, "source": "manual", "access_token": "tok-2"},
|
||||
{"id": "cred-3", "label": "c", "auth_type": "api_key", "priority": 2, "source": "manual", "access_token": "tok-3"},
|
||||
],
|
||||
)
|
||||
|
||||
pool.remove_entry("cred-2")
|
||||
|
||||
remaining = sorted(pool.entries(), key=lambda e: e.priority)
|
||||
assert [e.priority for e in remaining] == [0, 1]
|
||||
assert [e.id for e in remaining] == ["cred-1", "cred-3"]
|
||||
|
||||
@@ -949,3 +949,94 @@ class TestAdversarialEdgeCases:
|
||||
e = MockAPIError("server error", status_code=500, body={"message": None})
|
||||
result = classify_api_error(e)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ── Test: SSL/TLS transient errors ─────────────────────────────────────
|
||||
|
||||
class TestSSLTransientPatterns:
|
||||
"""SSL/TLS alerts mid-stream should retry as timeout, not unknown, and
|
||||
should NOT trigger context compression even on a large session.
|
||||
|
||||
Motivation: OpenSSL 3.x changed TLS alert error code format
|
||||
(`SSLV3_ALERT_BAD_RECORD_MAC` → `SSL/TLS_ALERT_BAD_RECORD_MAC`),
|
||||
breaking string-exact matching in downstream retry logic. We match
|
||||
stable substrings instead.
|
||||
"""
|
||||
|
||||
def test_bad_record_mac_classifies_as_timeout(self):
|
||||
"""OpenSSL 3.x mid-stream bad record mac alert."""
|
||||
e = Exception("[SSL: BAD_RECORD_MAC] sslv3 alert bad record mac (_ssl.c:2580)")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_openssl_3x_format_classifies_as_timeout(self):
|
||||
"""New format `ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC` still matches
|
||||
because we key on both space- and underscore-separated forms of
|
||||
the stable `bad_record_mac` token."""
|
||||
e = Exception("ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC during streaming")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_tls_alert_internal_error_classifies_as_timeout(self):
|
||||
e = Exception("[SSL: TLSV1_ALERT_INTERNAL_ERROR] tlsv1 alert internal error")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_ssl_handshake_failure_classifies_as_timeout(self):
|
||||
e = Exception("ssl handshake failure during mid-stream")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
|
||||
def test_ssl_prefix_classifies_as_timeout(self):
|
||||
"""Python's generic '[SSL: XYZ]' prefix from the ssl module."""
|
||||
e = Exception("[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
|
||||
def test_ssl_alert_on_large_session_does_not_compress(self):
|
||||
"""Critical: SSL alerts on big contexts must NOT trigger context
|
||||
compression — compression is expensive and won't fix a transport
|
||||
hiccup. This is why _SSL_TRANSIENT_PATTERNS is separate from
|
||||
_SERVER_DISCONNECT_PATTERNS.
|
||||
"""
|
||||
e = Exception("[SSL: BAD_RECORD_MAC] sslv3 alert bad record mac")
|
||||
result = classify_api_error(
|
||||
e,
|
||||
approx_tokens=180000, # 90% of a 200k-context window
|
||||
context_length=200000,
|
||||
num_messages=300,
|
||||
)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.should_compress is False
|
||||
|
||||
def test_plain_disconnect_on_large_session_still_compresses(self):
|
||||
"""Regression guard: the context-overflow-via-disconnect path
|
||||
(non-SSL disconnects on large sessions) must still trigger
|
||||
compression. Only SSL-specific disconnects skip it.
|
||||
"""
|
||||
e = Exception("Server disconnected without sending a response")
|
||||
result = classify_api_error(
|
||||
e,
|
||||
approx_tokens=180000,
|
||||
context_length=200000,
|
||||
num_messages=300,
|
||||
)
|
||||
assert result.reason == FailoverReason.context_overflow
|
||||
assert result.should_compress is True
|
||||
|
||||
def test_real_ssl_error_type_classifies_as_timeout(self):
|
||||
"""Real ssl.SSLError instance — the type name alone (not message)
|
||||
should route to the transport bucket."""
|
||||
import ssl
|
||||
e = ssl.SSLError("arbitrary ssl error")
|
||||
result = classify_api_error(e)
|
||||
assert result.reason == FailoverReason.timeout
|
||||
assert result.retryable is True
|
||||
|
||||
@@ -807,6 +807,24 @@ class TestPromptBuilderConstants:
|
||||
# check that this test is calibrated correctly).
|
||||
assert "include MEDIA:" in PLATFORM_HINTS["telegram"]
|
||||
|
||||
def test_platform_hints_mattermost(self):
|
||||
hint = PLATFORM_HINTS["mattermost"]
|
||||
assert "Mattermost" in hint
|
||||
assert "MEDIA:" in hint
|
||||
assert "Markdown" in hint
|
||||
|
||||
def test_platform_hints_matrix(self):
|
||||
hint = PLATFORM_HINTS["matrix"]
|
||||
assert "Matrix" in hint
|
||||
assert "MEDIA:" in hint
|
||||
assert "Markdown" in hint
|
||||
|
||||
def test_platform_hints_feishu(self):
|
||||
hint = PLATFORM_HINTS["feishu"]
|
||||
assert "Feishu" in hint
|
||||
assert "MEDIA:" in hint
|
||||
assert "Markdown" in hint
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Environment hints
|
||||
|
||||
@@ -39,6 +39,73 @@ def test_normalize_usage_openai_subtracts_cached_prompt_tokens():
|
||||
assert normalized.output_tokens == 700
|
||||
|
||||
|
||||
def test_normalize_usage_openai_reads_top_level_anthropic_cache_fields():
|
||||
"""Some OpenAI-compatible proxies (OpenRouter, Vercel AI Gateway, Cline) expose
|
||||
Anthropic-style cache token counts at the top level of the usage object when
|
||||
routing Claude models, instead of nesting them in prompt_tokens_details.
|
||||
|
||||
Regression guard for the bug fixed in cline/cline#10266 — before this fix,
|
||||
the chat-completions branch of normalize_usage() only read
|
||||
prompt_tokens_details.cache_write_tokens and completely missed the
|
||||
cache_creation_input_tokens case, so cache writes showed as 0 and reflected
|
||||
inputTokens were overstated by the cache-write amount.
|
||||
"""
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=200,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=500),
|
||||
cache_creation_input_tokens=300,
|
||||
)
|
||||
|
||||
normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions")
|
||||
|
||||
# Expected: cache read from prompt_tokens_details.cached_tokens (preferred),
|
||||
# cache write from top-level cache_creation_input_tokens (fallback).
|
||||
assert normalized.cache_read_tokens == 500
|
||||
assert normalized.cache_write_tokens == 300
|
||||
# input_tokens = prompt_total - cache_read - cache_write = 1000 - 500 - 300 = 200
|
||||
assert normalized.input_tokens == 200
|
||||
assert normalized.output_tokens == 200
|
||||
|
||||
|
||||
def test_normalize_usage_openai_reads_top_level_cache_read_when_details_missing():
|
||||
"""Some proxies expose only top-level Anthropic-style fields with no
|
||||
prompt_tokens_details object. Regression guard for cline/cline#10266.
|
||||
"""
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=200,
|
||||
cache_read_input_tokens=500,
|
||||
cache_creation_input_tokens=300,
|
||||
)
|
||||
|
||||
normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions")
|
||||
|
||||
assert normalized.cache_read_tokens == 500
|
||||
assert normalized.cache_write_tokens == 300
|
||||
assert normalized.input_tokens == 200
|
||||
|
||||
|
||||
def test_normalize_usage_openai_prefers_prompt_tokens_details_over_top_level():
|
||||
"""When both prompt_tokens_details and top-level Anthropic fields are
|
||||
present, we prefer the OpenAI-standard nested fields. Top-level Anthropic
|
||||
fields are only a fallback when the nested ones are absent/zero.
|
||||
"""
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=200,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=600, cache_write_tokens=150),
|
||||
# Intentionally different values — proving we ignore these when details exist.
|
||||
cache_read_input_tokens=999,
|
||||
cache_creation_input_tokens=999,
|
||||
)
|
||||
|
||||
normalized = normalize_usage(usage, provider="openrouter", api_mode="chat_completions")
|
||||
|
||||
assert normalized.cache_read_tokens == 600
|
||||
assert normalized.cache_write_tokens == 150
|
||||
|
||||
|
||||
def test_openrouter_models_api_pricing_is_converted_from_per_token_to_per_million(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"agent.usage_pricing.fetch_model_metadata",
|
||||
|
||||
@@ -149,3 +149,95 @@ class TestMapFinishReason:
|
||||
|
||||
def test_none_reason(self):
|
||||
assert map_finish_reason(None, self.ANTHROPIC_MAP) == "stop"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compat property tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestToolCallBackwardCompat:
|
||||
"""Test duck-typing properties that let ToolCall pass through code expecting
|
||||
the old SimpleNamespace(id, type, function=SimpleNamespace(name, arguments)) shape."""
|
||||
|
||||
def test_type_is_function(self):
|
||||
tc = ToolCall(id="1", name="search", arguments='{"q":"test"}')
|
||||
assert tc.type == "function"
|
||||
|
||||
def test_function_returns_self(self):
|
||||
tc = ToolCall(id="1", name="search", arguments='{"q":"test"}')
|
||||
assert tc.function is tc
|
||||
|
||||
def test_function_name_matches(self):
|
||||
tc = ToolCall(id="1", name="search", arguments='{"q":"test"}')
|
||||
assert tc.function.name == "search"
|
||||
assert tc.function.name == tc.name
|
||||
|
||||
def test_function_arguments_matches(self):
|
||||
tc = ToolCall(id="1", name="search", arguments='{"q":"test"}')
|
||||
assert tc.function.arguments == '{"q":"test"}'
|
||||
assert tc.function.arguments == tc.arguments
|
||||
|
||||
def test_call_id_from_provider_data(self):
|
||||
tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"call_id": "c1"})
|
||||
assert tc.call_id == "c1"
|
||||
|
||||
def test_call_id_none_when_no_provider_data(self):
|
||||
tc = ToolCall(id="1", name="fn", arguments="{}", provider_data=None)
|
||||
assert tc.call_id is None
|
||||
|
||||
def test_response_item_id_from_provider_data(self):
|
||||
tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"response_item_id": "r1"})
|
||||
assert tc.response_item_id == "r1"
|
||||
|
||||
def test_response_item_id_none_when_missing(self):
|
||||
tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"call_id": "c1"})
|
||||
assert tc.response_item_id is None
|
||||
|
||||
def test_getattr_pattern_matches_agent_loop(self):
|
||||
"""run_agent.py uses getattr(tool_call, 'call_id', None) — verify it works."""
|
||||
tc = ToolCall(id="1", name="fn", arguments="{}", provider_data={"call_id": "c1"})
|
||||
assert getattr(tc, "call_id", None) == "c1"
|
||||
tc_no_pd = ToolCall(id="1", name="fn", arguments="{}")
|
||||
assert getattr(tc_no_pd, "call_id", None) is None
|
||||
|
||||
|
||||
class TestNormalizedResponseBackwardCompat:
|
||||
"""Test properties that replaced _nr_to_assistant_message() shim."""
|
||||
|
||||
def test_reasoning_content_from_provider_data(self):
|
||||
nr = NormalizedResponse(
|
||||
content="hi", tool_calls=None, finish_reason="stop",
|
||||
provider_data={"reasoning_content": "thought process"},
|
||||
)
|
||||
assert nr.reasoning_content == "thought process"
|
||||
|
||||
def test_reasoning_content_none_when_absent(self):
|
||||
nr = NormalizedResponse(content="hi", tool_calls=None, finish_reason="stop")
|
||||
assert nr.reasoning_content is None
|
||||
|
||||
def test_reasoning_details_from_provider_data(self):
|
||||
details = [{"type": "thinking", "thinking": "hmm"}]
|
||||
nr = NormalizedResponse(
|
||||
content="hi", tool_calls=None, finish_reason="stop",
|
||||
provider_data={"reasoning_details": details},
|
||||
)
|
||||
assert nr.reasoning_details == details
|
||||
|
||||
def test_reasoning_details_none_when_no_provider_data(self):
|
||||
nr = NormalizedResponse(
|
||||
content="hi", tool_calls=None, finish_reason="stop",
|
||||
provider_data=None,
|
||||
)
|
||||
assert nr.reasoning_details is None
|
||||
|
||||
def test_codex_reasoning_items_from_provider_data(self):
|
||||
items = ["item1", "item2"]
|
||||
nr = NormalizedResponse(
|
||||
content="hi", tool_calls=None, finish_reason="stop",
|
||||
provider_data={"codex_reasoning_items": items},
|
||||
)
|
||||
assert nr.codex_reasoning_items == items
|
||||
|
||||
def test_codex_reasoning_items_none_when_absent(self):
|
||||
nr = NormalizedResponse(content="hi", tool_calls=None, finish_reason="stop")
|
||||
assert nr.codex_reasoning_items is None
|
||||
|
||||
@@ -1537,7 +1537,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
adapter._dispatch_inbound_event.assert_awaited_once()
|
||||
event = adapter._dispatch_inbound_event.await_args.args[0]
|
||||
self.assertEqual(event.message_type, MessageType.TEXT)
|
||||
self.assertEqual(event.source.user_id, "ou_user")
|
||||
self.assertEqual(event.source.user_id, "u_user") # tenant-scoped user_id preferred over app-scoped open_id
|
||||
self.assertEqual(event.source.user_name, "张三")
|
||||
self.assertEqual(event.source.user_id_alt, "on_union")
|
||||
self.assertEqual(event.source.chat_name, "Feishu DM")
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Regression tests for approval-state cleanup on session boundaries."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
from tools import approval as approval_mod
|
||||
from tools.approval import (
|
||||
approve_session,
|
||||
enable_session_yolo,
|
||||
is_approved,
|
||||
is_session_yolo_enabled,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_approval_state():
|
||||
approval_mod._gateway_queues.clear()
|
||||
approval_mod._gateway_notify_cbs.clear()
|
||||
approval_mod._session_approved.clear()
|
||||
approval_mod._session_yolo.clear()
|
||||
approval_mod._permanent_approved.clear()
|
||||
approval_mod._pending.clear()
|
||||
yield
|
||||
approval_mod._gateway_queues.clear()
|
||||
approval_mod._gateway_notify_cbs.clear()
|
||||
approval_mod._session_approved.clear()
|
||||
approval_mod._session_yolo.clear()
|
||||
approval_mod._permanent_approved.clear()
|
||||
approval_mod._pending.clear()
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
user_id="u1",
|
||||
chat_id="c1",
|
||||
user_name="tester",
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=_make_source(), message_id="m1")
|
||||
|
||||
|
||||
def _make_entry(session_id: str, source: SessionSource | None = None) -> SessionEntry:
|
||||
source = source or _make_source()
|
||||
return SessionEntry(
|
||||
session_key=build_session_key(source),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
origin=source,
|
||||
platform=source.platform,
|
||||
chat_type=source.chat_type,
|
||||
)
|
||||
|
||||
|
||||
def _make_resume_runner():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
source = _make_source()
|
||||
session_key = build_session_key(source)
|
||||
current_entry = _make_entry("current-session", source)
|
||||
resumed_entry = _make_entry("resumed-session", source)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._background_tasks = set()
|
||||
runner._async_flush_memories = AsyncMock()
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._busy_ack_ts = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._agent_cache_lock = None
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = current_entry
|
||||
runner.session_store.switch_session.return_value = resumed_entry
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.resolve_session_by_title.return_value = "resumed-session"
|
||||
runner._session_db.get_session_title.return_value = "Resumed Work"
|
||||
return runner, session_key
|
||||
|
||||
|
||||
def _make_branch_runner():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
source = _make_source()
|
||||
session_key = build_session_key(source)
|
||||
current_entry = _make_entry("current-session", source)
|
||||
branched_entry = _make_entry("branched-session", source)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner.config = {}
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._busy_ack_ts = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._agent_cache_lock = None
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = current_entry
|
||||
runner.session_store.load_transcript.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "world"},
|
||||
]
|
||||
runner.session_store.switch_session.return_value = branched_entry
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.get_session_title.return_value = "Current Work"
|
||||
runner._session_db.get_next_title_in_lineage.return_value = "Current Work #2"
|
||||
return runner, session_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_clears_session_scoped_approval_and_yolo_state():
|
||||
runner, session_key = _make_resume_runner()
|
||||
other_key = "agent:main:telegram:dm:other-chat"
|
||||
|
||||
approve_session(session_key, "recursive delete")
|
||||
approve_session(other_key, "recursive delete")
|
||||
enable_session_yolo(session_key)
|
||||
enable_session_yolo(other_key)
|
||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
||||
|
||||
result = await runner._handle_resume_command(_make_event("/resume Resumed Work"))
|
||||
|
||||
assert "Resumed session" in result
|
||||
assert is_approved(session_key, "recursive delete") is False
|
||||
assert is_session_yolo_enabled(session_key) is False
|
||||
assert session_key not in runner._pending_approvals
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert is_session_yolo_enabled(other_key) is True
|
||||
assert other_key in runner._pending_approvals
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_clears_session_scoped_approval_and_yolo_state():
|
||||
runner, session_key = _make_branch_runner()
|
||||
other_key = "agent:main:telegram:dm:other-chat"
|
||||
|
||||
approve_session(session_key, "recursive delete")
|
||||
approve_session(other_key, "recursive delete")
|
||||
enable_session_yolo(session_key)
|
||||
enable_session_yolo(other_key)
|
||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
||||
|
||||
result = await runner._handle_branch_command(_make_event("/branch"))
|
||||
|
||||
assert "Branched to" in result
|
||||
assert is_approved(session_key, "recursive delete") is False
|
||||
assert is_session_yolo_enabled(session_key) is False
|
||||
assert session_key not in runner._pending_approvals
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert is_session_yolo_enabled(other_key) is True
|
||||
assert other_key in runner._pending_approvals
|
||||
|
||||
|
||||
def test_clear_session_boundary_security_state_is_scoped():
|
||||
"""The helper must wipe only the target session's approval/yolo state.
|
||||
|
||||
Also exercises the /new reset path indirectly: /new calls this helper,
|
||||
so if the helper is scoped correctly, /new's clearing is correct too.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._pending_approvals = {}
|
||||
|
||||
source = _make_source()
|
||||
session_key = build_session_key(source)
|
||||
other_key = "agent:main:telegram:dm:other-chat"
|
||||
|
||||
approve_session(session_key, "recursive delete")
|
||||
approve_session(other_key, "recursive delete")
|
||||
enable_session_yolo(session_key)
|
||||
enable_session_yolo(other_key)
|
||||
runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/demo"}
|
||||
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
||||
|
||||
runner._clear_session_boundary_security_state(session_key)
|
||||
|
||||
# Target session cleared
|
||||
assert is_approved(session_key, "recursive delete") is False
|
||||
assert is_session_yolo_enabled(session_key) is False
|
||||
assert session_key not in runner._pending_approvals
|
||||
# Other session untouched
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert is_session_yolo_enabled(other_key) is True
|
||||
assert other_key in runner._pending_approvals
|
||||
|
||||
# Empty session_key is a no-op
|
||||
runner._clear_session_boundary_security_state("")
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
@@ -164,7 +164,7 @@ class TestArceeURLMapping:
|
||||
assert "arceeai" in _PROVIDER_PREFIXES
|
||||
|
||||
def test_trajectory_compressor_detects_arcee(self):
|
||||
import trajectory_compressor as tc
|
||||
import scripts.trajectory_compressor as tc
|
||||
comp = tc.TrajectoryCompressor.__new__(tc.TrajectoryCompressor)
|
||||
comp.config = types.SimpleNamespace(base_url="https://api.arcee.ai/api/v1")
|
||||
assert comp._detect_provider() == "arcee"
|
||||
|
||||
@@ -5,6 +5,8 @@ import pwd
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gateway as gateway_cli
|
||||
from gateway.restart import (
|
||||
DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
|
||||
@@ -1083,6 +1085,116 @@ class TestEnsureUserSystemdEnv:
|
||||
assert calls == []
|
||||
|
||||
|
||||
class TestPreflightUserSystemd:
|
||||
"""Tests for _preflight_user_systemd() — D-Bus reachability before systemctl --user.
|
||||
|
||||
Covers issue #5130 / Rick's RHEL 9.6 SSH scenario: setup tries to start the
|
||||
gateway via ``systemctl --user start`` in a shell with no user D-Bus session,
|
||||
which previously failed with a raw ``CalledProcessError`` and no remediation.
|
||||
"""
|
||||
|
||||
def test_noop_when_bus_socket_exists(self, monkeypatch):
|
||||
"""Socket already there (desktop / linger + prior login) → no-op."""
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_user_dbus_socket_path",
|
||||
lambda: type("P", (), {"exists": lambda self: True})(),
|
||||
)
|
||||
# Should not raise, no subprocess calls needed.
|
||||
gateway_cli._preflight_user_systemd()
|
||||
|
||||
def test_raises_when_linger_disabled_and_loginctl_denied(self, monkeypatch):
|
||||
"""Rick's scenario: no D-Bus, no linger, non-root SSH → clear error."""
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_user_dbus_socket_path",
|
||||
lambda: type("P", (), {"exists": lambda self: False})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_linger_status", lambda: (False, ""),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda _: "/usr/bin/loginctl")
|
||||
|
||||
class _Result:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "Interactive authentication required."
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess, "run", lambda *a, **kw: _Result(),
|
||||
)
|
||||
|
||||
with pytest.raises(gateway_cli.UserSystemdUnavailableError) as exc_info:
|
||||
gateway_cli._preflight_user_systemd()
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert "sudo loginctl enable-linger" in msg
|
||||
assert "hermes gateway run" in msg # foreground fallback mentioned
|
||||
assert "Interactive authentication required" in msg
|
||||
|
||||
def test_raises_when_loginctl_missing(self, monkeypatch):
|
||||
"""No loginctl binary at all → suggest sudo install + manual fix."""
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_user_dbus_socket_path",
|
||||
lambda: type("P", (), {"exists": lambda self: False})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_linger_status",
|
||||
lambda: (None, "loginctl not found"),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda _: None)
|
||||
|
||||
with pytest.raises(gateway_cli.UserSystemdUnavailableError) as exc_info:
|
||||
gateway_cli._preflight_user_systemd()
|
||||
|
||||
assert "sudo loginctl enable-linger" in str(exc_info.value)
|
||||
|
||||
def test_linger_enabled_but_socket_still_missing(self, monkeypatch):
|
||||
"""Edge case: linger says yes but the bus socket never came up."""
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_user_dbus_socket_path",
|
||||
lambda: type("P", (), {"exists": lambda self: False})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_linger_status", lambda: (True, ""),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_wait_for_user_dbus_socket", lambda timeout=3.0: False,
|
||||
)
|
||||
|
||||
with pytest.raises(gateway_cli.UserSystemdUnavailableError) as exc_info:
|
||||
gateway_cli._preflight_user_systemd()
|
||||
|
||||
assert "linger is enabled" in str(exc_info.value)
|
||||
|
||||
def test_enable_linger_succeeds_and_socket_appears(self, monkeypatch, capsys):
|
||||
"""Happy remediation path: polkit allows enable-linger, socket spawns."""
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_user_dbus_socket_path",
|
||||
lambda: type("P", (), {"exists": lambda self: False})(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_linger_status", lambda: (False, ""),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda _: "/usr/bin/loginctl")
|
||||
|
||||
class _OkResult:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli.subprocess, "run", lambda *a, **kw: _OkResult(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_wait_for_user_dbus_socket",
|
||||
lambda timeout=5.0: True,
|
||||
)
|
||||
|
||||
# Should not raise.
|
||||
gateway_cli._preflight_user_systemd()
|
||||
out = capsys.readouterr().out
|
||||
assert "Enabled linger" in out
|
||||
|
||||
|
||||
class TestProfileArg:
|
||||
"""Tests for _profile_arg — returns '--profile <name>' for named profiles."""
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests for --ignore-user-config and --ignore-rules flags on `hermes chat`.
|
||||
|
||||
Ported from openai/codex#18646 (`feat: add --ignore-user-config and --ignore-rules`).
|
||||
Codex's flags fully isolate a run from user-level config and exec-policy .rules
|
||||
files. In Hermes the equivalent isolation is:
|
||||
|
||||
* ``--ignore-user-config`` → skip ``~/.hermes/config.yaml`` in ``load_cli_config()``
|
||||
(credentials in ``.env`` are still loaded).
|
||||
* ``--ignore-rules`` → skip AGENTS.md / SOUL.md / .cursorrules auto-injection
|
||||
and persistent memory (maps to ``AIAgent(skip_context_files=True,
|
||||
skip_memory=True)``).
|
||||
|
||||
Both flags are wired via env vars so they work cleanly across the
|
||||
argparse → cmd_chat → cli.main() → HermesCLI → AIAgent call chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
"""Ensure the two env-var gates start AND end each test in a known state.
|
||||
|
||||
Some tests here write directly to ``os.environ`` (mirroring the real
|
||||
``cmd_chat`` logic), so ``monkeypatch.delenv`` alone isn't enough —
|
||||
those writes aren't tracked by monkeypatch and won't be undone by it.
|
||||
We add explicit cleanup on yield to prevent cross-test pollution.
|
||||
"""
|
||||
for var in ("HERMES_IGNORE_USER_CONFIG", "HERMES_IGNORE_RULES"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
yield
|
||||
for var in ("HERMES_IGNORE_USER_CONFIG", "HERMES_IGNORE_RULES"):
|
||||
os.environ.pop(var, None)
|
||||
|
||||
|
||||
class TestIgnoreUserConfigEnvGate:
|
||||
"""``load_cli_config()`` must honour ``HERMES_IGNORE_USER_CONFIG=1``.
|
||||
|
||||
When the env var is set, user config at ``<hermes_home>/config.yaml`` is
|
||||
skipped even if present — the function returns only the built-in defaults
|
||||
(merged with the project-level ``cli-config.yaml`` fallback).
|
||||
"""
|
||||
|
||||
def _write_user_config(self, tmp_path, model_default):
|
||||
config_yaml = textwrap.dedent(
|
||||
f"""
|
||||
model:
|
||||
default: {model_default}
|
||||
provider: openrouter
|
||||
agent:
|
||||
system_prompt: "from user config"
|
||||
"""
|
||||
).lstrip()
|
||||
(tmp_path / "config.yaml").write_text(config_yaml)
|
||||
|
||||
def _reload_cli(self, monkeypatch, tmp_path):
|
||||
"""Point cli._hermes_home at tmp_path and return a fresh load_cli_config."""
|
||||
import cli
|
||||
monkeypatch.setattr(cli, "_hermes_home", tmp_path)
|
||||
return cli.load_cli_config
|
||||
|
||||
def test_user_config_loaded_when_flag_unset(self, tmp_path, monkeypatch):
|
||||
self._write_user_config(tmp_path, "anthropic/claude-sonnet-4.6")
|
||||
load_cli_config = self._reload_cli(monkeypatch, tmp_path)
|
||||
|
||||
cfg = load_cli_config()
|
||||
|
||||
# User config value wins
|
||||
assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6"
|
||||
assert cfg["agent"]["system_prompt"] == "from user config"
|
||||
|
||||
def test_user_config_skipped_when_flag_set(self, tmp_path, monkeypatch):
|
||||
"""With HERMES_IGNORE_USER_CONFIG=1, user config.yaml is ignored.
|
||||
|
||||
The built-in default ``model.default`` is empty string (no user override),
|
||||
and the user's ``agent.system_prompt`` is not seen.
|
||||
"""
|
||||
self._write_user_config(tmp_path, "anthropic/claude-sonnet-4.6")
|
||||
monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "1")
|
||||
|
||||
load_cli_config = self._reload_cli(monkeypatch, tmp_path)
|
||||
cfg = load_cli_config()
|
||||
|
||||
# User-set "system_prompt: from user config" MUST NOT leak through
|
||||
assert cfg["agent"].get("system_prompt", "") != "from user config"
|
||||
|
||||
# User-set model.default MUST NOT leak through — either the built-in
|
||||
# default ("" or unset) or a project-level fallback, but never the
|
||||
# user's value
|
||||
assert cfg["model"].get("default", "") != "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_flag_ignored_when_set_to_other_value(self, tmp_path, monkeypatch):
|
||||
"""Only the literal value "1" activates the bypass, matching the yolo pattern."""
|
||||
self._write_user_config(tmp_path, "anthropic/claude-sonnet-4.6")
|
||||
monkeypatch.setenv("HERMES_IGNORE_USER_CONFIG", "true") # not "1"
|
||||
|
||||
load_cli_config = self._reload_cli(monkeypatch, tmp_path)
|
||||
cfg = load_cli_config()
|
||||
|
||||
# "true" != "1", so user config IS loaded
|
||||
assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
class TestIgnoreRulesEnvGate:
|
||||
"""The constructor / env var must propagate to ``HermesCLI.ignore_rules``
|
||||
so ``AIAgent`` is built with ``skip_context_files=True`` and
|
||||
``skip_memory=True``.
|
||||
"""
|
||||
|
||||
def test_env_var_enables_ignore_rules(self, monkeypatch):
|
||||
"""Setting HERMES_IGNORE_RULES=1 flips HermesCLI.ignore_rules True."""
|
||||
monkeypatch.setenv("HERMES_IGNORE_RULES", "1")
|
||||
|
||||
# Import HermesCLI lazily — cli.py has heavy module-init side effects
|
||||
# that we don't want to run at test collection time.
|
||||
import cli
|
||||
importlib.reload(cli)
|
||||
|
||||
# Build only enough of HermesCLI to reach the ignore_rules assignment.
|
||||
# The full __init__ pulls in provider/auth/session DB, so we cheat:
|
||||
# create the object via object.__new__ and manually run the assignment
|
||||
# the same way the real constructor does.
|
||||
obj = object.__new__(cli.HermesCLI)
|
||||
# Replicate the exact logic from cli.py HermesCLI.__init__:
|
||||
ignore_rules = False # constructor default
|
||||
obj.ignore_rules = ignore_rules or os.environ.get("HERMES_IGNORE_RULES") == "1"
|
||||
|
||||
assert obj.ignore_rules is True
|
||||
|
||||
def test_constructor_flag_alone_enables_ignore_rules(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False)
|
||||
import cli
|
||||
obj = object.__new__(cli.HermesCLI)
|
||||
ignore_rules = True # constructor argument
|
||||
obj.ignore_rules = ignore_rules or os.environ.get("HERMES_IGNORE_RULES") == "1"
|
||||
assert obj.ignore_rules is True
|
||||
|
||||
def test_neither_flag_nor_env_leaves_rules_enabled(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False)
|
||||
import cli
|
||||
obj = object.__new__(cli.HermesCLI)
|
||||
ignore_rules = False
|
||||
obj.ignore_rules = ignore_rules or os.environ.get("HERMES_IGNORE_RULES") == "1"
|
||||
assert obj.ignore_rules is False
|
||||
|
||||
|
||||
class TestCmdChatWiring:
|
||||
"""The wiring inside ``cmd_chat()`` in ``hermes_cli/main.py`` must set
|
||||
both env vars before importing ``cli`` (which evaluates
|
||||
``load_cli_config()`` at module import).
|
||||
"""
|
||||
|
||||
def _simulate_cmd_chat_env_setup(self, args):
|
||||
"""Replicate the exact snippet from cmd_chat in main.py."""
|
||||
if getattr(args, "ignore_user_config", False):
|
||||
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
|
||||
if getattr(args, "ignore_rules", False):
|
||||
os.environ["HERMES_IGNORE_RULES"] = "1"
|
||||
|
||||
def test_both_flags_set_both_env_vars(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
|
||||
monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False)
|
||||
|
||||
class FakeArgs:
|
||||
ignore_user_config = True
|
||||
ignore_rules = True
|
||||
|
||||
self._simulate_cmd_chat_env_setup(FakeArgs())
|
||||
|
||||
assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
|
||||
assert os.environ.get("HERMES_IGNORE_RULES") == "1"
|
||||
|
||||
def test_only_ignore_user_config(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
|
||||
monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False)
|
||||
|
||||
class FakeArgs:
|
||||
ignore_user_config = True
|
||||
ignore_rules = False
|
||||
|
||||
self._simulate_cmd_chat_env_setup(FakeArgs())
|
||||
|
||||
assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
|
||||
assert "HERMES_IGNORE_RULES" not in os.environ
|
||||
|
||||
def test_flags_absent_sets_nothing(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_IGNORE_USER_CONFIG", raising=False)
|
||||
monkeypatch.delenv("HERMES_IGNORE_RULES", raising=False)
|
||||
|
||||
class FakeArgs:
|
||||
pass # no attributes at all — getattr fallback must handle
|
||||
|
||||
self._simulate_cmd_chat_env_setup(FakeArgs())
|
||||
|
||||
assert "HERMES_IGNORE_USER_CONFIG" not in os.environ
|
||||
assert "HERMES_IGNORE_RULES" not in os.environ
|
||||
|
||||
|
||||
class TestArgparseFlagsRegistered:
|
||||
"""Verify the `chat` subparser actually exposes --ignore-user-config
|
||||
and --ignore-rules. This is the contract test for the CLI surface.
|
||||
"""
|
||||
|
||||
def test_flags_present_in_chat_parser(self):
|
||||
"""Parse a synthetic chat invocation and check both attributes exist."""
|
||||
# Minimal argparse tree matching the real chat subparser shape for the
|
||||
# two flags under test. If someone removes the flag from main.py, this
|
||||
# test keeps passing in isolation — but the E2E test below catches it.
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(prog="hermes")
|
||||
subs = parser.add_subparsers(dest="command")
|
||||
chat = subs.add_parser("chat")
|
||||
chat.add_argument("--ignore-user-config", action="store_true", default=False)
|
||||
chat.add_argument("--ignore-rules", action="store_true", default=False)
|
||||
|
||||
args = parser.parse_args(["chat", "--ignore-user-config", "--ignore-rules"])
|
||||
assert args.ignore_user_config is True
|
||||
assert args.ignore_rules is True
|
||||
|
||||
def test_main_py_registers_both_flags(self):
|
||||
"""E2E: the real hermes_cli/main.py parser accepts both flags.
|
||||
|
||||
We invoke the real argparse tree builder from hermes_cli.main.
|
||||
"""
|
||||
import hermes_cli.main as hm
|
||||
|
||||
# hm has a helper that builds the argparse tree inside main().
|
||||
# We can extract it by catching the SystemExit on --help.
|
||||
# Simpler: just grep the source for the flag strings. Both approaches
|
||||
# are brittle; we use a combined test.
|
||||
import inspect
|
||||
src = inspect.getsource(hm)
|
||||
assert '"--ignore-user-config"' in src, \
|
||||
"chat subparser must register --ignore-user-config"
|
||||
assert '"--ignore-rules"' in src, \
|
||||
"chat subparser must register --ignore-rules"
|
||||
# And the cmd_chat env-var wiring must be present
|
||||
assert "HERMES_IGNORE_USER_CONFIG" in src
|
||||
assert "HERMES_IGNORE_RULES" in src
|
||||
@@ -6,6 +6,8 @@ Covers `_plugin_image_gen_providers`, `_visible_providers`, and
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import image_gen_registry
|
||||
@@ -172,3 +174,78 @@ class TestConfigWriting:
|
||||
|
||||
assert config["image_gen"]["provider"] == "noenv"
|
||||
assert config["image_gen"]["model"] == "noenv-model-v1"
|
||||
|
||||
def test_reconfiguring_plugin_provider_writes_provider_and_model(self, monkeypatch, tmp_path):
|
||||
"""The reconfigure path should switch image_gen away from managed FAL
|
||||
and onto the selected plugin provider."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
image_gen_registry.register_provider(_FakeProvider("testopenai"))
|
||||
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
||||
monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "")
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
"get_env_value",
|
||||
lambda key: "sk-test" if key == "OPENAI_API_KEY" else "",
|
||||
)
|
||||
|
||||
config = {"image_gen": {"use_gateway": True}}
|
||||
provider_row = {
|
||||
"name": "OpenAI",
|
||||
"env_vars": [{"key": "OPENAI_API_KEY", "prompt": "OpenAI API key"}],
|
||||
"image_gen_plugin_name": "testopenai",
|
||||
}
|
||||
|
||||
tools_config._reconfigure_provider(provider_row, config)
|
||||
|
||||
assert config["image_gen"]["provider"] == "testopenai"
|
||||
assert config["image_gen"]["model"] == "testopenai-model-v1"
|
||||
assert config["image_gen"]["use_gateway"] is False
|
||||
|
||||
def test_plugin_provider_active_overrides_managed_nous_active_label(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
"get_nous_subscription_features",
|
||||
lambda config: SimpleNamespace(
|
||||
features={"image_gen": SimpleNamespace(managed_by_nous=True)}
|
||||
),
|
||||
)
|
||||
|
||||
config = {"image_gen": {"provider": "openai", "use_gateway": False}}
|
||||
nous_row = {
|
||||
"name": "Nous Subscription",
|
||||
"managed_nous_feature": "image_gen",
|
||||
}
|
||||
openai_row = {
|
||||
"name": "OpenAI",
|
||||
"image_gen_plugin_name": "openai",
|
||||
}
|
||||
|
||||
assert tools_config._is_provider_active(openai_row, config) is True
|
||||
assert tools_config._is_provider_active(nous_row, config) is False
|
||||
|
||||
def test_reconfiguring_fal_clears_plugin_provider(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
||||
monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "")
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
"get_env_value",
|
||||
lambda key: "fal-key" if key == "FAL_KEY" else "",
|
||||
)
|
||||
|
||||
config = {"image_gen": {"provider": "openai", "use_gateway": False}}
|
||||
provider_row = {
|
||||
"name": "FAL.ai",
|
||||
"env_vars": [{"key": "FAL_KEY", "prompt": "FAL API key"}],
|
||||
"imagegen_backend": "fal",
|
||||
}
|
||||
|
||||
tools_config._reconfigure_provider(provider_row, config)
|
||||
|
||||
assert config["image_gen"]["provider"] == "fal"
|
||||
assert config["image_gen"]["use_gateway"] is False
|
||||
|
||||
@@ -253,3 +253,148 @@ def test_list_dedupes_dict_model_matching_singular_default(monkeypatch):
|
||||
ds_rows = [p for p in providers if p["name"] == "DeepSeek"]
|
||||
assert ds_rows[0]["models"].count("deepseek-chat") == 1
|
||||
assert ds_rows[0]["models"] == ["deepseek-chat", "deepseek-reasoner"]
|
||||
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# #9210: group custom_providers by (base_url, api_key) in /model picker
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_authenticated_providers_groups_same_endpoint(monkeypatch):
|
||||
"""Multiple custom_providers entries sharing a base_url+api_key must be
|
||||
returned as a single picker row with all their models merged."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom",
|
||||
current_base_url="http://localhost:11434/v1",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{"name": "Ollama — MiniMax M2.7", "base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama", "model": "minimax-m2.7"},
|
||||
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama", "model": "glm-5.1"},
|
||||
{"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama", "model": "qwen3-coder"},
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
custom_groups = [p for p in providers if p.get("is_user_defined")]
|
||||
assert len(custom_groups) == 1, (
|
||||
"Expected 1 group for shared endpoint, got "
|
||||
f"{[p['slug'] for p in custom_groups]}"
|
||||
)
|
||||
group = custom_groups[0]
|
||||
assert set(group["models"]) == {"minimax-m2.7", "glm-5.1", "qwen3-coder"}
|
||||
assert group["total_models"] == 3
|
||||
# Per-model suffix stripped from display name
|
||||
assert group["name"] == "Ollama"
|
||||
|
||||
|
||||
def test_list_authenticated_providers_current_endpoint_uses_current_slug(monkeypatch):
|
||||
"""When current_base_url matches the grouped endpoint, the slug must
|
||||
equal current_provider so picker selection routes through the live
|
||||
credential pipeline."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom",
|
||||
current_base_url="http://localhost:11434/v1",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama", "model": "glm-5.1"},
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
matches = [p for p in providers if p.get("is_user_defined")]
|
||||
assert len(matches) == 1
|
||||
group = matches[0]
|
||||
assert group["slug"] == "custom"
|
||||
assert group["is_current"] is True
|
||||
|
||||
|
||||
def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch):
|
||||
"""Entries with different base_urls must produce separate picker rows
|
||||
even if some display names happen to be similar."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama", "model": "glm-5.1"},
|
||||
{"name": "Moonshot", "base_url": "https://api.moonshot.cn/v1",
|
||||
"api_key": "sk-m", "model": "moonshot-v1"},
|
||||
{"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama", "model": "qwen3-coder"},
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
custom_groups = [p for p in providers if p.get("is_user_defined")]
|
||||
assert len(custom_groups) == 2
|
||||
# Ollama endpoint collapses to one row with both models
|
||||
ollama = next(p for p in custom_groups if p["name"] == "Ollama")
|
||||
assert set(ollama["models"]) == {"glm-5.1", "qwen3-coder"}
|
||||
moonshot = next(p for p in custom_groups if p["name"] == "Moonshot")
|
||||
assert moonshot["models"] == ["moonshot-v1"]
|
||||
|
||||
|
||||
def test_list_authenticated_providers_same_url_different_keys_disambiguated(monkeypatch):
|
||||
"""Two custom_providers entries with the same base_url but different
|
||||
api_keys (and identical cleaned names) must both stay visible in the
|
||||
picker — slug is suffixed to disambiguate."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{"name": "OpenAI — key A", "base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-AAA", "model": "gpt-5.4"},
|
||||
{"name": "OpenAI — key B", "base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-BBB", "model": "gpt-4.6"},
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
custom_groups = [p for p in providers if p.get("is_user_defined")]
|
||||
assert len(custom_groups) == 2
|
||||
slugs = sorted(p["slug"] for p in custom_groups)
|
||||
# First group keeps the base slug, second gets a numeric suffix
|
||||
assert slugs == ["custom:openai", "custom:openai-2"]
|
||||
# Each row has a distinct model
|
||||
models = {p["slug"]: p["models"] for p in custom_groups}
|
||||
assert models["custom:openai"] == ["gpt-5.4"]
|
||||
assert models["custom:openai-2"] == ["gpt-4.6"]
|
||||
|
||||
|
||||
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
|
||||
"""After grouping six entries into one row, total_models must reflect
|
||||
the full count, and every grouped model appears in the list."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
entries = [
|
||||
{"name": f"Ollama \u2014 Model {i}", "base_url": "http://localhost:11434/v1",
|
||||
"api_key": "ollama", "model": f"model-{i}"}
|
||||
for i in range(6)
|
||||
]
|
||||
providers = list_authenticated_providers(
|
||||
user_providers={},
|
||||
custom_providers=entries,
|
||||
max_models=4,
|
||||
)
|
||||
|
||||
groups = [p for p in providers if p.get("is_user_defined")]
|
||||
assert len(groups) == 1
|
||||
group = groups[0]
|
||||
assert group["total_models"] == 6
|
||||
# All six models are preserved in the grouped row.
|
||||
assert sorted(group["models"]) == sorted(f"model-{i}" for i in range(6))
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for the models.dev-preferred merge behavior in provider_model_ids
|
||||
and list_authenticated_providers.
|
||||
|
||||
These guard the contract:
|
||||
|
||||
* For providers in ``_MODELS_DEV_PREFERRED`` (opencode-go, opencode-zen,
|
||||
xiaomi, deepseek, smaller inference providers), both the CLI model
|
||||
picker path (``provider_model_ids``) and the gateway ``/model`` picker
|
||||
path (``list_authenticated_providers``) merge fresh models.dev entries
|
||||
on top of the curated static list.
|
||||
* OpenRouter and Nous Portal are NEVER merged — they keep their curated
|
||||
(OpenRouter) or live-Portal (Nous) semantics.
|
||||
* If models.dev is unreachable (offline / CI), the curated list is the
|
||||
fallback — no crash, no empty list.
|
||||
|
||||
Merging is what lets new models (e.g. ``mimo-v2.5-pro`` on opencode-go)
|
||||
appear in ``/model`` without a Hermes release.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.models import (
|
||||
_MODELS_DEV_PREFERRED,
|
||||
_merge_with_models_dev,
|
||||
provider_model_ids,
|
||||
)
|
||||
|
||||
|
||||
class TestMergeHelper:
|
||||
def test_merge_empty_mdev_returns_curated(self):
|
||||
"""When models.dev returns nothing, curated list is preserved verbatim."""
|
||||
with patch("agent.models_dev.list_agentic_models", return_value=[]):
|
||||
out = _merge_with_models_dev("opencode-go", ["mimo-v2-pro", "kimi-k2.6"])
|
||||
assert out == ["mimo-v2-pro", "kimi-k2.6"]
|
||||
|
||||
def test_merge_mdev_raises_returns_curated(self):
|
||||
"""Offline / broken models.dev must not break the catalog path."""
|
||||
def boom(_provider):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
with patch("agent.models_dev.list_agentic_models", side_effect=boom):
|
||||
out = _merge_with_models_dev("opencode-go", ["mimo-v2-pro"])
|
||||
assert out == ["mimo-v2-pro"]
|
||||
|
||||
def test_merge_mdev_first_then_curated_extras(self):
|
||||
"""models.dev entries come first; curated-only entries are appended."""
|
||||
mdev = ["mimo-v2.5-pro", "mimo-v2-pro", "kimi-k2.6"]
|
||||
curated = ["kimi-k2.6", "kimi-k2.5", "mimo-v2-pro"] # kimi-k2.5 is curated-only
|
||||
with patch("agent.models_dev.list_agentic_models", return_value=mdev):
|
||||
out = _merge_with_models_dev("opencode-go", curated)
|
||||
# models.dev entries first (in order), then curated-only entries
|
||||
assert out == ["mimo-v2.5-pro", "mimo-v2-pro", "kimi-k2.6", "kimi-k2.5"]
|
||||
|
||||
def test_merge_case_insensitive_dedup(self):
|
||||
"""Dedup is case-insensitive but preserves the first occurrence's casing."""
|
||||
mdev = ["MiniMax-M2.7"]
|
||||
curated = ["minimax-m2.7", "minimax-m2.5"]
|
||||
with patch("agent.models_dev.list_agentic_models", return_value=mdev):
|
||||
out = _merge_with_models_dev("minimax", curated)
|
||||
# models.dev casing wins since it came first
|
||||
assert out == ["MiniMax-M2.7", "minimax-m2.5"]
|
||||
|
||||
|
||||
class TestProviderModelIdsPreferred:
|
||||
def test_opencode_go_is_preferred(self):
|
||||
assert "opencode-go" in _MODELS_DEV_PREFERRED
|
||||
|
||||
def test_opencode_go_includes_fresh_models_dev_entries(self):
|
||||
"""provider_model_ids('opencode-go') adds models.dev entries on top."""
|
||||
mdev = ["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "kimi-k2.6"]
|
||||
with patch("agent.models_dev.list_agentic_models", return_value=mdev):
|
||||
out = provider_model_ids("opencode-go")
|
||||
# Fresh models must surface (this is exactly the reported bug fix:
|
||||
# mimo-v2.5-pro should be pickable on opencode-go).
|
||||
assert "mimo-v2.5-pro" in out
|
||||
assert "mimo-v2.5" in out
|
||||
# Curated entries are still present.
|
||||
assert "mimo-v2-pro" in out
|
||||
assert "kimi-k2.6" in out
|
||||
|
||||
def test_opencode_go_offline_falls_back_to_curated(self):
|
||||
"""Offline models.dev → curated-only list, no crash."""
|
||||
with patch("agent.models_dev.list_agentic_models", return_value=[]):
|
||||
out = provider_model_ids("opencode-go")
|
||||
# Curated floor (see hermes_cli/models.py _PROVIDER_MODELS["opencode-go"])
|
||||
assert "mimo-v2-pro" in out
|
||||
assert "kimi-k2.6" in out
|
||||
|
||||
def test_opencode_zen_includes_fresh_models(self):
|
||||
"""opencode-zen follows the same pattern as opencode-go."""
|
||||
assert "opencode-zen" in _MODELS_DEV_PREFERRED
|
||||
mdev = ["claude-opus-4-7", "kimi-k2.6", "glm-5.1"]
|
||||
with patch("agent.models_dev.list_agentic_models", return_value=mdev):
|
||||
out = provider_model_ids("opencode-zen")
|
||||
assert "claude-opus-4-7" in out
|
||||
assert "kimi-k2.6" in out
|
||||
|
||||
|
||||
class TestOpenRouterAndNousUnchanged:
|
||||
"""Per Teknium: openrouter and nous are NEVER merged with models.dev."""
|
||||
|
||||
def test_openrouter_not_in_preferred_set(self):
|
||||
assert "openrouter" not in _MODELS_DEV_PREFERRED
|
||||
|
||||
def test_nous_not_in_preferred_set(self):
|
||||
assert "nous" not in _MODELS_DEV_PREFERRED
|
||||
|
||||
def test_openrouter_does_not_call_merge(self):
|
||||
"""openrouter takes its own live path — merge helper must NOT run."""
|
||||
with patch(
|
||||
"hermes_cli.models._merge_with_models_dev",
|
||||
side_effect=AssertionError("merge should not be called for openrouter"),
|
||||
):
|
||||
# Even if model_ids() fails for some other reason, we just care
|
||||
# that the merge path isn't invoked.
|
||||
try:
|
||||
provider_model_ids("openrouter")
|
||||
except AssertionError:
|
||||
raise
|
||||
except Exception:
|
||||
pass # model_ids() may fail in the hermetic test env — that's fine.
|
||||
@@ -6,16 +6,41 @@ from unittest.mock import patch
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
||||
# Minimum set of models that must be present for opencode-go no matter
|
||||
# whether the picker sourced its list from curated-only or curated+models.dev.
|
||||
# The curated list in hermes_cli/models.py defines the floor; models.dev only
|
||||
# ever adds names on top of it via _merge_with_models_dev.
|
||||
_OPENCODE_GO_REQUIRED = {
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
}
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"OPENCODE_GO_API_KEY": "test-key"}, clear=False)
|
||||
def test_opencode_go_appears_when_api_key_set():
|
||||
"""opencode-go should appear in list_authenticated_providers when OPENCODE_GO_API_KEY is set."""
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
|
||||
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
|
||||
|
||||
# Find opencode-go in results
|
||||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
|
||||
|
||||
assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set"
|
||||
assert opencode_go["models"] == ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
|
||||
# Behavior check: the curated floor must be present. The list may also
|
||||
# include extra models.dev entries (e.g. mimo-v2.5-pro) when the registry
|
||||
# is reachable — that's the whole point of the models.dev-preferred merge
|
||||
# introduced for opencode-go, so don't pin to an exact list here.
|
||||
present = set(opencode_go["models"])
|
||||
missing = _OPENCODE_GO_REQUIRED - present
|
||||
assert not missing, (
|
||||
f"opencode-go picker should include the curated floor; missing: {sorted(missing)}. "
|
||||
f"Got: {opencode_go['models']}"
|
||||
)
|
||||
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
|
||||
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
|
||||
# the API is unavailable, e.g. in CI).
|
||||
@@ -26,10 +51,10 @@ def test_opencode_go_not_appears_when_no_creds():
|
||||
"""opencode-go should NOT appear when no credentials are set."""
|
||||
# Ensure OPENCODE_GO_API_KEY is not set
|
||||
env_without_key = {k: v for k, v in os.environ.items() if k != "OPENCODE_GO_API_KEY"}
|
||||
|
||||
|
||||
with patch.dict(os.environ, env_without_key, clear=True):
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
|
||||
|
||||
# opencode-go should not be in results
|
||||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
assert opencode_go is None, "opencode-go should not appear without credentials"
|
||||
|
||||
@@ -455,6 +455,47 @@ class TestExportImport:
|
||||
with pytest.raises(FileExistsError):
|
||||
import_profile(str(archive_path), name="coder")
|
||||
|
||||
def test_import_with_explicit_name_does_not_mutate_existing_archive_root_profile(
|
||||
self, profile_env, tmp_path
|
||||
):
|
||||
create_profile("victim", no_alias=True)
|
||||
victim_dir = get_profile_dir("victim")
|
||||
(victim_dir / "marker.txt").write_text("original")
|
||||
|
||||
archive_path = tmp_path / "export" / "victim.tar.gz"
|
||||
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(archive_path, "w:gz") as tf:
|
||||
data = b"imported"
|
||||
info = tarfile.TarInfo("victim/marker.txt")
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
|
||||
imported = import_profile(str(archive_path), name="renamed")
|
||||
|
||||
assert imported == get_profile_dir("renamed")
|
||||
assert (imported / "marker.txt").read_text() == "imported"
|
||||
assert (victim_dir / "marker.txt").read_text() == "original"
|
||||
|
||||
def test_import_rejects_archive_with_multiple_top_level_directories(
|
||||
self, profile_env, tmp_path
|
||||
):
|
||||
archive_path = tmp_path / "export" / "multi-root.tar.gz"
|
||||
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(archive_path, "w:gz") as tf:
|
||||
for member_name, data in (
|
||||
("alpha/marker.txt", b"a"),
|
||||
("beta/marker.txt", b"b"),
|
||||
):
|
||||
info = tarfile.TarInfo(member_name)
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
|
||||
with pytest.raises(ValueError, match="exactly one top-level directory"):
|
||||
import_profile(str(archive_path), name="coder")
|
||||
|
||||
assert not get_profile_dir("coder").exists()
|
||||
|
||||
def test_import_rejects_traversal_archive_member(self, profile_env, tmp_path):
|
||||
archive_path = tmp_path / "export" / "evil.tar.gz"
|
||||
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -135,3 +135,48 @@ class TestNormalizeCustomProviderEntry:
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry, provider_key="")
|
||||
assert result is None
|
||||
|
||||
def test_models_list_converted_to_dict(self):
|
||||
"""List-format models should be preserved as an empty-value dict so
|
||||
/model picks them up instead of showing the provider with (0) models."""
|
||||
entry = {
|
||||
"name": "tencent-coding-plan",
|
||||
"base_url": "https://api.lkeap.cloud.tencent.com/coding/v3",
|
||||
"models": ["glm-5", "kimi-k2.5", "minimax-m2.5"],
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry)
|
||||
assert result is not None
|
||||
assert result["models"] == {"glm-5": {}, "kimi-k2.5": {}, "minimax-m2.5": {}}
|
||||
|
||||
def test_models_dict_preserved(self):
|
||||
"""Dict-format models should pass through unchanged."""
|
||||
entry = {
|
||||
"name": "acme",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"models": {"gpt-foo": {"context_length": 32000}},
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry)
|
||||
assert result is not None
|
||||
assert result["models"] == {"gpt-foo": {"context_length": 32000}}
|
||||
|
||||
def test_models_list_filters_empty_and_non_string(self):
|
||||
"""List entries that are empty strings or non-strings are skipped."""
|
||||
entry = {
|
||||
"name": "acme",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"models": ["valid", "", None, 42, " ", "also-valid"],
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry)
|
||||
assert result is not None
|
||||
assert result["models"] == {"valid": {}, "also-valid": {}}
|
||||
|
||||
def test_models_empty_list_omitted(self):
|
||||
"""Empty list (falsy) should not produce a models key."""
|
||||
entry = {
|
||||
"name": "acme",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"models": [],
|
||||
}
|
||||
result = _normalize_custom_provider_entry(entry)
|
||||
assert result is not None
|
||||
assert "models" not in result
|
||||
|
||||
@@ -104,7 +104,7 @@ def main():
|
||||
test_file = create_test_dataset()
|
||||
|
||||
print(f"\n📝 To run the test manually:")
|
||||
print(f" python batch_runner.py \\")
|
||||
print(f" python scripts/batch_runner.py \\")
|
||||
print(f" --dataset_file={test_file} \\")
|
||||
print(f" --batch_size=2 \\")
|
||||
print(f" --run_name={run_name} \\")
|
||||
@@ -112,7 +112,7 @@ def main():
|
||||
print(f" --num_workers=2")
|
||||
|
||||
print(f"\n💡 Or test with different distributions:")
|
||||
print(f" python batch_runner.py --list_distributions")
|
||||
print(f" python scripts/batch_runner.py --list_distributions")
|
||||
|
||||
print(f"\n🔍 After running, you can verify output with:")
|
||||
print(f" python tests/test_batch_runner.py --verify")
|
||||
|
||||
@@ -30,7 +30,7 @@ from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
import traceback
|
||||
|
||||
# Add project root to path to import batch_runner
|
||||
# Add project root to path to import scripts.batch_runner
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ def test_current_implementation():
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
# Import here to avoid issues if module changes
|
||||
from batch_runner import BatchRunner
|
||||
from scripts.batch_runner import BatchRunner
|
||||
|
||||
checkpoint_file = output_dir / "checkpoint.json"
|
||||
|
||||
@@ -229,7 +229,7 @@ def test_interruption_and_resume():
|
||||
if output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
from batch_runner import BatchRunner
|
||||
from scripts.batch_runner import BatchRunner
|
||||
|
||||
checkpoint_file = output_dir / "checkpoint.json"
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Tests for the bundled ``openai-codex`` image_gen plugin.
|
||||
|
||||
Mirrors ``test_openai_provider.py`` but targets the standalone
|
||||
Codex/ChatGPT-OAuth-backed provider that uses the Responses
|
||||
``image_generation`` tool path instead of the ``images.generate`` REST
|
||||
endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
# The plugin directory uses a hyphen, which is not a valid Python identifier
|
||||
# for the dotted-import form. Load it via importlib so tests don't need to
|
||||
# touch sys.path or rename the directory.
|
||||
codex_plugin = importlib.import_module("plugins.image_gen.openai-codex")
|
||||
|
||||
|
||||
# 1×1 transparent PNG — valid bytes for save_b64_image()
|
||||
_PNG_HEX = (
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
|
||||
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
|
||||
"ae426082"
|
||||
)
|
||||
|
||||
|
||||
def _b64_png() -> str:
|
||||
import base64
|
||||
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, events, final_response):
|
||||
self._events = list(events)
|
||||
self._final = final_response
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._events)
|
||||
|
||||
def get_final_response(self):
|
||||
return self._final
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
yield tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(monkeypatch):
|
||||
# Codex plugin is API-key-independent; clear it to make the test honest.
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
return codex_plugin.OpenAICodexImageGenProvider()
|
||||
|
||||
|
||||
# ── Metadata ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMetadata:
|
||||
def test_name(self, provider):
|
||||
assert provider.name == "openai-codex"
|
||||
|
||||
def test_display_name(self, provider):
|
||||
assert provider.display_name == "OpenAI (Codex auth)"
|
||||
|
||||
def test_default_model(self, provider):
|
||||
assert provider.default_model() == "gpt-image-2-medium"
|
||||
|
||||
def test_list_models_three_tiers(self, provider):
|
||||
ids = [m["id"] for m in provider.list_models()]
|
||||
assert ids == ["gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"]
|
||||
|
||||
def test_setup_schema_has_no_required_env_vars(self, provider):
|
||||
schema = provider.get_setup_schema()
|
||||
assert schema["env_vars"] == []
|
||||
assert schema["badge"] == "free"
|
||||
|
||||
|
||||
# ── Availability ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAvailability:
|
||||
def test_unavailable_without_codex_token(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
|
||||
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
|
||||
|
||||
def test_available_with_codex_token(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True
|
||||
|
||||
def test_openai_api_key_alone_is_not_enough(self, monkeypatch):
|
||||
# Codex plugin is intentionally orthogonal to the API-key plugin —
|
||||
# the API key alone must NOT make it appear available.
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
|
||||
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
|
||||
|
||||
|
||||
# ── Generate ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_returns_auth_error_without_codex_token(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
def test_returns_invalid_argument_for_empty_prompt(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
result = provider.generate(" ")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_argument"
|
||||
|
||||
def test_generate_uses_codex_stream_path(self, provider, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
output_item = SimpleNamespace(
|
||||
type="image_generation_call",
|
||||
status="generating",
|
||||
id="ig_test",
|
||||
result=_b64_png(),
|
||||
)
|
||||
done_event = SimpleNamespace(type="response.output_item.done", item=output_item)
|
||||
final_response = SimpleNamespace(output=[], status="completed", output_text="")
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(
|
||||
stream=lambda **kwargs: _FakeStream([done_event], final_response)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
|
||||
|
||||
result = provider.generate("a cat", aspect_ratio="landscape")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["model"] == "gpt-image-2-medium"
|
||||
assert result["provider"] == "openai-codex"
|
||||
assert result["quality"] == "medium"
|
||||
|
||||
saved = Path(result["image"])
|
||||
assert saved.exists()
|
||||
assert saved.parent == tmp_path / "cache" / "images"
|
||||
# Filename prefix differs from the API-key plugin so cache audits can
|
||||
# tell the two backends apart.
|
||||
assert saved.name.startswith("openai_codex_")
|
||||
|
||||
def test_codex_stream_request_shape(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
captured = {}
|
||||
|
||||
def _stream(**kwargs):
|
||||
captured.update(kwargs)
|
||||
output_item = SimpleNamespace(
|
||||
type="image_generation_call",
|
||||
status="generating",
|
||||
id="ig_test",
|
||||
result=_b64_png(),
|
||||
)
|
||||
done_event = SimpleNamespace(type="response.output_item.done", item=output_item)
|
||||
final_response = SimpleNamespace(output=[], status="completed", output_text="")
|
||||
return _FakeStream([done_event], final_response)
|
||||
|
||||
fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_stream))
|
||||
monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
|
||||
|
||||
result = provider.generate("a cat", aspect_ratio="portrait")
|
||||
assert result["success"] is True
|
||||
|
||||
assert captured["model"] == "gpt-5.4"
|
||||
assert captured["store"] is False
|
||||
assert captured["input"][0]["type"] == "message"
|
||||
assert captured["input"][0]["role"] == "user"
|
||||
assert captured["input"][0]["content"][0]["type"] == "input_text"
|
||||
assert captured["tool_choice"]["type"] == "allowed_tools"
|
||||
assert captured["tool_choice"]["mode"] == "required"
|
||||
assert captured["tool_choice"]["tools"] == [{"type": "image_generation"}]
|
||||
|
||||
tool = captured["tools"][0]
|
||||
assert tool["type"] == "image_generation"
|
||||
assert tool["model"] == "gpt-image-2"
|
||||
assert tool["quality"] == "medium"
|
||||
assert tool["size"] == "1024x1536"
|
||||
assert tool["output_format"] == "png"
|
||||
assert tool["background"] == "opaque"
|
||||
assert tool["partial_images"] == 1
|
||||
|
||||
def test_partial_image_event_used_when_done_missing(self, provider, monkeypatch):
|
||||
"""If the stream never emits output_item.done, fall back to the
|
||||
partial_image event so users at least get the latest preview frame."""
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
partial_event = SimpleNamespace(
|
||||
type="response.image_generation_call.partial_image",
|
||||
partial_image_b64=_b64_png(),
|
||||
)
|
||||
final_response = SimpleNamespace(output=[], status="completed", output_text="")
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(
|
||||
stream=lambda **kwargs: _FakeStream([partial_event], final_response)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
|
||||
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is True
|
||||
assert Path(result["image"]).exists()
|
||||
|
||||
def test_final_response_sweep_recovers_image(self, provider, monkeypatch):
|
||||
"""If no image_generation_call event arrives mid-stream, the
|
||||
post-stream final-response sweep should still find the image."""
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
final_item = SimpleNamespace(
|
||||
type="image_generation_call",
|
||||
status="completed",
|
||||
id="ig_final",
|
||||
result=_b64_png(),
|
||||
)
|
||||
final_response = SimpleNamespace(output=[final_item], status="completed", output_text="")
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(
|
||||
stream=lambda **kwargs: _FakeStream([], final_response)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
|
||||
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is True
|
||||
assert Path(result["image"]).exists()
|
||||
|
||||
def test_empty_response_returns_error(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
final_response = SimpleNamespace(output=[], status="completed", output_text="")
|
||||
fake_client = SimpleNamespace(
|
||||
responses=SimpleNamespace(
|
||||
stream=lambda **kwargs: _FakeStream([], final_response)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
|
||||
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_client_init_failure_returns_auth_error(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: None)
|
||||
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
def test_stream_exception_returns_api_error(self, provider, monkeypatch):
|
||||
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
|
||||
|
||||
def _boom(**kwargs):
|
||||
raise RuntimeError("cloudflare 403")
|
||||
|
||||
fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_boom))
|
||||
monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
|
||||
|
||||
result = provider.generate("a cat")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "api_error"
|
||||
assert "cloudflare 403" in result["error"]
|
||||
|
||||
|
||||
# ── Plugin entry point ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_register_calls_register_image_gen_provider(self):
|
||||
registered = []
|
||||
|
||||
class _Ctx:
|
||||
def register_image_gen_provider(self, prov):
|
||||
registered.append(prov)
|
||||
|
||||
codex_plugin.register(_Ctx())
|
||||
assert len(registered) == 1
|
||||
assert registered[0].name == "openai-codex"
|
||||
@@ -47,31 +47,31 @@ def _make_anthropic_response(blocks, stop_reason: str = "max_tokens"):
|
||||
|
||||
|
||||
class TestTruncatedAnthropicResponseNormalization:
|
||||
"""normalize_anthropic_response() gives us the shape _build_assistant_message expects."""
|
||||
"""AnthropicTransport.normalize_response() gives us the shape _build_assistant_message expects."""
|
||||
|
||||
def test_text_only_truncation_produces_text_content_no_tool_calls(self):
|
||||
"""Pure-text Anthropic truncation → continuation path should fire."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
from agent.transports import get_transport
|
||||
|
||||
response = _make_anthropic_response(
|
||||
[_make_anthropic_text_block("partial response that was cut off")]
|
||||
)
|
||||
msg, finish = normalize_anthropic_response(response)
|
||||
nr = get_transport("anthropic_messages").normalize_response(response)
|
||||
|
||||
# The continuation block checks these two attributes:
|
||||
# assistant_message.content → appended to truncated_response_prefix
|
||||
# assistant_message.tool_calls → guards the text-retry branch
|
||||
assert msg.content is not None
|
||||
assert "partial response" in msg.content
|
||||
assert not msg.tool_calls, (
|
||||
assert nr.content is not None
|
||||
assert "partial response" in nr.content
|
||||
assert not nr.tool_calls, (
|
||||
"Pure-text truncation must have no tool_calls so the text-continuation "
|
||||
"branch (not the tool-retry branch) fires"
|
||||
)
|
||||
assert finish == "length", "max_tokens stop_reason must map to OpenAI-style 'length'"
|
||||
assert nr.finish_reason == "length", "max_tokens stop_reason must map to OpenAI-style 'length'"
|
||||
|
||||
def test_truncated_tool_call_produces_tool_calls(self):
|
||||
"""Tool-use truncation → tool-call retry path should fire."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
from agent.transports import get_transport
|
||||
|
||||
response = _make_anthropic_response(
|
||||
[
|
||||
@@ -79,24 +79,24 @@ class TestTruncatedAnthropicResponseNormalization:
|
||||
_make_anthropic_tool_use_block(),
|
||||
]
|
||||
)
|
||||
msg, finish = normalize_anthropic_response(response)
|
||||
nr = get_transport("anthropic_messages").normalize_response(response)
|
||||
|
||||
assert bool(msg.tool_calls), (
|
||||
assert bool(nr.tool_calls), (
|
||||
"Truncation mid-tool_use must expose tool_calls so the "
|
||||
"tool-call retry branch fires instead of text continuation"
|
||||
)
|
||||
assert finish == "length"
|
||||
assert nr.finish_reason == "length"
|
||||
|
||||
def test_empty_content_does_not_crash(self):
|
||||
"""Empty response.content — defensive: treat as a truncation with no text."""
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
from agent.transports import get_transport
|
||||
|
||||
response = _make_anthropic_response([])
|
||||
msg, finish = normalize_anthropic_response(response)
|
||||
nr = get_transport("anthropic_messages").normalize_response(response)
|
||||
# Depending on the adapter, content may be "" or None — both are
|
||||
# acceptable; what matters is no exception.
|
||||
assert msg is not None
|
||||
assert not msg.tool_calls
|
||||
assert nr is not None
|
||||
assert not nr.tool_calls
|
||||
|
||||
|
||||
class TestContinuationLogicBranching:
|
||||
|
||||
@@ -372,6 +372,91 @@ class TestStripThinkBlocks:
|
||||
assert "mixed" not in result
|
||||
assert "final" in result
|
||||
|
||||
# ─── Tool-call XML block stripping (openclaw/openclaw#67318) ─────────
|
||||
# Some open models (notably Gemma variants via OpenRouter) emit
|
||||
# standalone tool-call XML inside assistant content instead of via the
|
||||
# structured `tool_calls` field. Left unstripped, raw XML leaks to
|
||||
# gateway users (Discord/Telegram/Matrix) and the CLI.
|
||||
|
||||
def test_tool_call_block_stripped(self, agent):
|
||||
text = '<tool_call>{"name": "read_file", "arguments": {"path": "/tmp/x"}}</tool_call> done'
|
||||
result = agent._strip_think_blocks(text)
|
||||
assert "<tool_call>" not in result
|
||||
assert "read_file" not in result
|
||||
assert "done" in result
|
||||
|
||||
def test_function_calls_block_stripped(self, agent):
|
||||
text = '<function_calls>[{"name":"x"}]</function_calls>after'
|
||||
result = agent._strip_think_blocks(text)
|
||||
assert "<function_calls>" not in result
|
||||
assert "after" in result
|
||||
|
||||
def test_gemma_function_name_block_stripped(self, agent):
|
||||
"""Gemma-style: <function name="read"><parameter>...</parameter></function>."""
|
||||
text = (
|
||||
'Let me check the file.\n'
|
||||
'<function name="read_file"><parameter name="path">/tmp/x.md</parameter></function>\n'
|
||||
'Here is the result.'
|
||||
)
|
||||
result = agent._strip_think_blocks(text)
|
||||
assert '<function name="read_file">' not in result
|
||||
assert "/tmp/x.md" not in result
|
||||
assert "Let me check the file." in result
|
||||
assert "Here is the result." in result
|
||||
|
||||
def test_gemma_function_multiline_payload_stripped(self, agent):
|
||||
text = (
|
||||
'Reading now.\n'
|
||||
'<function name="read_file">\n'
|
||||
' <parameter name="path">/etc/passwd</parameter>\n'
|
||||
'</function>\n'
|
||||
'Done.'
|
||||
)
|
||||
result = agent._strip_think_blocks(text)
|
||||
assert "/etc/passwd" not in result
|
||||
assert "Reading now." in result
|
||||
assert "Done." in result
|
||||
|
||||
def test_function_mention_in_prose_preserved(self, agent):
|
||||
"""'Use <function> in JavaScript.' — no name attr, not at block boundary
|
||||
in a way that suggests tool call. Must survive."""
|
||||
text = "In JS you can use <function> declarations for hoisting."
|
||||
result = agent._strip_think_blocks(text)
|
||||
# Prose mention has no name="..." attribute -> not stripped
|
||||
assert "declarations for hoisting" in result
|
||||
|
||||
def test_function_with_attr_in_middle_of_sentence_preserved(self, agent):
|
||||
"""Docs example: 'Use <function name="x">...</function> in docs.'
|
||||
The sentence-middle position without a preceding punctuation block
|
||||
boundary means it is NOT stripped. Prose context remains."""
|
||||
text = 'You can write <function name="x">y</function> inline.'
|
||||
result = agent._strip_think_blocks(text)
|
||||
# Without a leading block boundary (no punctuation before), leaves intact
|
||||
assert "You can write" in result
|
||||
assert "inline" in result
|
||||
|
||||
def test_stray_function_close_tag_removed(self, agent):
|
||||
text = "answer</function> trailing"
|
||||
result = agent._strip_think_blocks(text)
|
||||
assert "</function>" not in result
|
||||
assert "answer" in result
|
||||
assert "trailing" in result
|
||||
|
||||
def test_dangling_function_open_tag_preserved(self, agent):
|
||||
"""A streamed-but-truncated <function name="..."> block with no close
|
||||
is intentionally NOT stripped (OpenClaw's asymmetry). The tail of a
|
||||
streaming reply may still be valuable to the user."""
|
||||
text = 'Checking: <function name="read">'
|
||||
result = agent._strip_think_blocks(text)
|
||||
assert "Checking:" in result
|
||||
|
||||
def test_mixed_reasoning_and_tool_call_both_stripped(self, agent):
|
||||
text = '<think>let me plan</think><tool_call>{"name":"x"}</tool_call>final answer'
|
||||
result = agent._strip_think_blocks(text)
|
||||
assert "let me plan" not in result
|
||||
assert "<tool_call>" not in result
|
||||
assert "final answer" in result
|
||||
|
||||
|
||||
class TestExtractReasoning:
|
||||
def test_reasoning_field(self, agent):
|
||||
|
||||
@@ -13,7 +13,8 @@ They do NOT boot the full AIAgent — the prologue-fix guarantees are pure
|
||||
function contracts at module scope.
|
||||
"""
|
||||
|
||||
from run_agent import _chat_content_to_responses_parts, _summarize_user_message_for_log
|
||||
from run_agent import _summarize_user_message_for_log
|
||||
from agent.codex_responses_adapter import _chat_content_to_responses_parts
|
||||
|
||||
|
||||
class TestSummarizeUserMessageForLog:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for cli.py::_strip_reasoning_tags — specifically the tool-call
|
||||
XML stripping added in openclaw/openclaw#67318 port.
|
||||
|
||||
The CLI has its own copy of the stripper because it needs to run on the
|
||||
final displayed assistant text (after streaming) without depending on the
|
||||
AIAgent instance. It must stay in sync with run_agent.py::_strip_think_blocks
|
||||
for tool-call tag coverage."""
|
||||
|
||||
import pytest
|
||||
|
||||
from cli import _strip_reasoning_tags
|
||||
|
||||
|
||||
class TestToolCallStripping:
|
||||
def test_tool_call_block_stripped(self):
|
||||
text = '<tool_call>{"name": "x"}</tool_call>result'
|
||||
result = _strip_reasoning_tags(text)
|
||||
assert "<tool_call>" not in result
|
||||
assert "result" in result
|
||||
|
||||
def test_function_calls_block_stripped(self):
|
||||
text = '<function_calls>[{}]</function_calls>\nanswer'
|
||||
result = _strip_reasoning_tags(text)
|
||||
assert "<function_calls>" not in result
|
||||
assert "answer" in result
|
||||
|
||||
def test_gemma_function_name_block_stripped(self):
|
||||
text = (
|
||||
'Reading.\n'
|
||||
'<function name="r"><parameter name="p">/tmp/x</parameter></function>\n'
|
||||
'Done.'
|
||||
)
|
||||
result = _strip_reasoning_tags(text)
|
||||
assert '<function name="r">' not in result
|
||||
assert "/tmp/x" not in result
|
||||
assert "Reading." in result
|
||||
assert "Done." in result
|
||||
|
||||
def test_prose_mention_of_function_preserved(self):
|
||||
text = "Use <function> declarations in JavaScript."
|
||||
result = _strip_reasoning_tags(text)
|
||||
assert "JavaScript" in result
|
||||
|
||||
def test_reasoning_still_stripped(self):
|
||||
"""Regression: make sure existing think-tag stripping still works."""
|
||||
text = "<think>reasoning</think> answer"
|
||||
result = _strip_reasoning_tags(text)
|
||||
assert "reasoning" not in result
|
||||
assert "answer" in result
|
||||
|
||||
def test_mixed_reasoning_and_tool_call(self):
|
||||
text = '<think>plan</think><tool_call>{"x":1}</tool_call>final'
|
||||
result = _strip_reasoning_tags(text)
|
||||
assert "plan" not in result
|
||||
assert "<tool_call>" not in result
|
||||
assert "final" in result
|
||||
|
||||
def test_stray_function_close(self):
|
||||
text = "visible</function> tail"
|
||||
result = _strip_reasoning_tags(text)
|
||||
assert "</function>" not in result
|
||||
assert "visible" in result
|
||||
assert "tail" in result
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _strip_reasoning_tags("") == ""
|
||||
|
||||
def test_plain_text_unchanged(self):
|
||||
assert _strip_reasoning_tags("just text") == "just text"
|
||||
@@ -8,11 +8,7 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# batch_runner uses relative imports, ensure project root is on path
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from batch_runner import BatchRunner, _process_batch_worker
|
||||
from scripts.batch_runner import BatchRunner, _process_batch_worker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -173,7 +169,7 @@ class TestBatchWorkerResumeBehavior:
|
||||
"toolsets_used": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("batch_runner._process_single_prompt", lambda *args, **kwargs: prompt_result)
|
||||
monkeypatch.setattr("scripts.batch_runner._process_single_prompt", lambda *args, **kwargs: prompt_result)
|
||||
|
||||
result = _process_batch_worker((
|
||||
1,
|
||||
|
||||
@@ -14,7 +14,7 @@ def test_run_task_kimi_omits_temperature():
|
||||
)
|
||||
mock_openai.return_value = client
|
||||
|
||||
from mini_swe_runner import MiniSWERunner
|
||||
from scripts.mini_swe_runner import MiniSWERunner
|
||||
|
||||
runner = MiniSWERunner(
|
||||
model="kimi-for-coding",
|
||||
@@ -42,7 +42,7 @@ def test_run_task_public_moonshot_kimi_k2_5_omits_temperature():
|
||||
)
|
||||
mock_openai.return_value = client
|
||||
|
||||
from mini_swe_runner import MiniSWERunner
|
||||
from scripts.mini_swe_runner import MiniSWERunner
|
||||
|
||||
runner = MiniSWERunner(
|
||||
model="kimi-k2.5",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user