Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
1ca9b19
feat: add network.force_ipv4 config to fix IPv6 timeout issues (#8196)
teknium1 Apr 12, 2026
8e00b3a
fix(cron): steer model away from explicit deliver targets that lose t…
teknium1 Apr 12, 2026
4cadfef
fix(cli): restore stacked tool progress scrollback in TUI (#8201)
teknium1 Apr 12, 2026
73f970f
fix: make gateway interrupt detection resilient to monitor task failures
teknium1 Apr 12, 2026
eb2a49f
fix: openai-codex and anthropic not appearing in /model picker for ex…
teknium1 Apr 12, 2026
1871227
feat: rebrand OpenClaw references to Hermes during migration
teknium1 Apr 12, 2026
36f57db
fix(migration): don't auto-archive OpenClaw source directory
opriz Apr 12, 2026
fdf55e0
feat(cli): show random tip on new session start (#8225)
teknium1 Apr 12, 2026
f53a5a7
fix: suppress duplicate completion notifications when agent already c…
teknium1 Apr 12, 2026
81ac62c
fix(weixin): split chatty short replies into separate bubbles, keep s…
bravohenry Apr 12, 2026
fee0e0d
fix(docker): run as non-root user, use virtualenv (salvage #5811)
m0n5t3r Apr 12, 2026
8b9d22a
revert: keep debian:13.4 full image instead of slim
teknium1 Apr 12, 2026
3162472
feat(tips): add 69 deeper hidden-gem tips (279 total) (#8237)
teknium1 Apr 12, 2026
c52f634
fix: list all available toolsets in delegate_task schema description …
teknium1 Apr 12, 2026
b1f13a8
fix(agent): route compression aux through live session runtime
counterposition Apr 12, 2026
078dba0
fix: three provider-related bugs (#8161, #8181, #8147) (#8243)
teknium1 Apr 12, 2026
a122097
fix: make skill loading instructions more aggressive in system prompt…
teknium1 Apr 12, 2026
ae6820a
fix(setup): validate base URL input in hermes model flow (#8264)
teknium1 Apr 12, 2026
4aa534e
fix(gateway): peek at pending message during interrupt instead of con…
teknium1 Apr 12, 2026
6d05e3d
fix(gateway): evict cached agent on /model switch + add diagnostic lo…
teknium1 Apr 12, 2026
95fa78e
fix: write refreshed Codex tokens back to ~/.codex/auth.json (#8277)
teknium1 Apr 12, 2026
00adbd0
chore: simplify Docker image tags
benbarclay Apr 12, 2026
b0d65c3
Merge pull request #8279 from NousResearch/chore/simplify-docker-tags
benbarclay Apr 12, 2026
b9af495
fix(matrix): restore verify_with_recovery_key after device key rotation
elkimek Apr 12, 2026
dd5b106
fix: register MATRIX_RECOVERY_KEY env var + document migration path
teknium1 Apr 12, 2026
b321330
feat: add WSL environment hint to system prompt (#8285)
teknium1 Apr 12, 2026
56e3ee2
fix: write update exit code before gateway restart (cgroup kill race)…
teknium1 Apr 12, 2026
b6b6b02
fix: prevent unwanted session auto-reset after graceful gateway resta…
teknium1 Apr 12, 2026
17c72f1
fix: make skill loading instructions more aggressive in system prompt…
teknium1 Apr 12, 2026
45e6090
fix: fall back to provider's default model when model config is empty…
teknium1 Apr 12, 2026
7a67b13
fix: title_generator no longer logs as 'compression' task
teknium1 Apr 12, 2026
4eecaf0
fix: prevent duplicate update prompt spam in gateway watcher (#8343)
teknium1 Apr 12, 2026
06a17c5
fix: improve profile creation UX — seed SOUL.md + credential warning …
teknium1 Apr 12, 2026
06290f6
fix: handle broken stdin in prompt_toolkit startup (#6393) (#8560)
teknium1 Apr 12, 2026
f295b17
fix: make agent_thread daemon to prevent orphan CLI processes on tab …
teknium1 Apr 12, 2026
a372c14
fix: strip <thought> tags from Gemma 4 responses in _strip_think_blocks
Unayung Apr 8, 2026
326d5fe
fix: also strip <thought> tags during streaming in cli.py
Unayung Apr 9, 2026
400fe9b
fix: add <thought> stripping to auxiliary_client + tests
teknium1 Apr 12, 2026
a9ebb33
fix: contextual error diagnostics for invalid API responses (#8565)
teknium1 Apr 12, 2026
d7785f4
feat(feishu): add scan-to-create onboarding for Feishu / Lark
Roy-oss1 Apr 11, 2026
1179918
fix: salvage follow-ups for Feishu QR onboarding (#7706)
teknium1 Apr 12, 2026
a4593f8
feat: make gateway 'still working' notification interval configurable…
teknium1 Apr 12, 2026
991496e
fix(security): add Windows archive detection and .zip extraction for …
XiaoXiao0221 Apr 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ jobs:
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
nousresearch/hermes-agent:latest
nousresearch/hermes-agent:${{ github.sha }}
tags: nousresearch/hermes-agent:latest
cache-from: type=gha
cache-to: type=gha,mode=max

Expand All @@ -83,9 +81,6 @@ jobs:
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
nousresearch/hermes-agent:latest
nousresearch/hermes-agent:${{ github.event.release.tag_name }}
nousresearch/hermes-agent:${{ github.sha }}
tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
29 changes: 23 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source
FROM debian:13.4

# Disable Python stdout buffering to ensure logs are printed immediately
ENV PYTHONUNBUFFERED=1

# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
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 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \
rm -rf /var/lib/apt/lists/*

# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes

COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/

COPY . /opt/hermes
WORKDIR /opt/hermes

# Install Python and Node dependencies in one layer, no cache
RUN pip install --no-cache-dir uv --break-system-packages && \
uv pip install --system --break-system-packages --no-cache -e ".[all]" && \
npm install --prefer-offline --no-audit && \
# Install Node dependencies and Playwright as root (--with-deps needs apt)
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
cd /opt/hermes/scripts/whatsapp-bridge && \
npm install --prefer-offline --no-audit && \
npm cache clean --force

WORKDIR /opt/hermes
# Hand ownership to hermes user, then install Python deps in a virtualenv
RUN chown -R hermes:hermes /opt/hermes
USER hermes

RUN uv venv && \
uv pip install --no-cache-dir -e ".[all]"

USER root
RUN chmod +x /opt/hermes/docker/entrypoint.sh

ENV HERMES_HOME=/opt/data
Expand Down
152 changes: 140 additions & 12 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,23 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:

_AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"})

_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode")


def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]:
"""Return a sanitized copy of a live main-runtime override."""
if not isinstance(main_runtime, dict):
return {}
normalized: Dict[str, str] = {}
for field in _MAIN_RUNTIME_FIELDS:
value = main_runtime.get(field)
if isinstance(value, str) and value.strip():
normalized[field] = value.strip()
provider = normalized.get("provider")
if provider:
normalized["provider"] = provider.lower()
return normalized


def _get_provider_chain() -> List[tuple]:
"""Return the ordered provider detection chain.
Expand Down Expand Up @@ -1130,7 +1147,7 @@ def _try_payment_fallback(
return None, None, ""


def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Full auto-detection chain.

Priority:
Expand All @@ -1142,14 +1159,20 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
"""
global auxiliary_is_nous, _stale_base_url_warned
auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins
runtime = _normalize_main_runtime(main_runtime)
runtime_provider = runtime.get("provider", "")
runtime_model = runtime.get("model", "")
runtime_base_url = runtime.get("base_url", "")
runtime_api_key = runtime.get("api_key", "")
runtime_api_mode = runtime.get("api_mode", "")

# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
# provider (not 'custom'). This catches the common "env poisoning"
# scenario where a user switches providers via `hermes model` but the
# old OPENAI_BASE_URL lingers in ~/.hermes/.env. ──
if not _stale_base_url_warned:
_env_base = os.getenv("OPENAI_BASE_URL", "").strip()
_cfg_provider = _read_main_provider()
_cfg_provider = runtime_provider or _read_main_provider()
if (_env_base and _cfg_provider
and _cfg_provider != "custom"
and not _cfg_provider.startswith("custom:")):
Expand All @@ -1163,12 +1186,25 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
_stale_base_url_warned = True

# ── Step 1: non-aggregator main provider → use main model directly ──
main_provider = _read_main_provider()
main_model = _read_main_model()
main_provider = runtime_provider or _read_main_provider()
main_model = runtime_model or _read_main_model()
if (main_provider and main_model
and main_provider not in _AGGREGATOR_PROVIDERS
and main_provider not in ("auto", "")):
client, resolved = resolve_provider_client(main_provider, main_model)
resolved_provider = main_provider
explicit_base_url = None
explicit_api_key = None
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
resolved_provider = "custom"
explicit_base_url = runtime_base_url
explicit_api_key = runtime_api_key or None
client, resolved = resolve_provider_client(
resolved_provider,
main_model,
explicit_base_url=explicit_base_url,
explicit_api_key=explicit_api_key,
api_mode=runtime_api_mode or None,
)
if client is not None:
logger.info("Auxiliary auto-detect: using main provider %s (%s)",
main_provider, resolved or main_model)
Expand Down Expand Up @@ -1249,6 +1285,7 @@ def resolve_provider_client(
explicit_base_url: str = None,
explicit_api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Central router: given a provider name and optional model, return a
configured client with the correct auth, base URL, and API format.
Expand Down Expand Up @@ -1319,7 +1356,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):

# ── Auto: try all providers in priority order ────────────────────
if provider == "auto":
client, resolved = _resolve_auto()
client, resolved = _resolve_auto(main_runtime=main_runtime)
if client is None:
return None, None
# When auto-detection lands on a non-OpenRouter provider (e.g. a
Expand Down Expand Up @@ -1543,7 +1580,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):

# ── Public API ──────────────────────────────────────────────────────────────

def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optional[str]]:
def get_text_auxiliary_client(
task: str = "",
*,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[OpenAI], Optional[str]]:
"""Return (client, default_model_slug) for text-only auxiliary tasks.

Args:
Expand All @@ -1560,10 +1601,11 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
main_runtime=main_runtime,
)


def get_async_text_auxiliary_client(task: str = ""):
def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Dict[str, Any]] = None):
"""Return (async_client, model_slug) for async consumers.

For standard providers returns (AsyncOpenAI, model). For Codex returns
Expand All @@ -1578,6 +1620,7 @@ def get_async_text_auxiliary_client(task: str = ""):
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
main_runtime=main_runtime,
)


Expand Down Expand Up @@ -1892,6 +1935,7 @@ def _get_cached_client(
base_url: str = None,
api_key: str = None,
api_mode: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
) -> Tuple[Optional[Any], Optional[str]]:
"""Get or create a cached client for the given provider.

Expand All @@ -1915,7 +1959,9 @@ def _get_cached_client(
loop_id = id(current_loop)
except RuntimeError:
pass
cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id)
runtime = _normalize_main_runtime(main_runtime)
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id, runtime_key)
with _client_cache_lock:
if cache_key in _client_cache:
cached_client, cached_default, cached_loop = _client_cache[cache_key]
Expand All @@ -1940,6 +1986,7 @@ def _get_cached_client(
explicit_base_url=base_url,
explicit_api_key=api_key,
api_mode=api_mode,
main_runtime=runtime,
)
if client is not None:
# For async clients, remember which loop they were created on so we
Expand Down Expand Up @@ -2065,6 +2112,75 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
return default


# ---------------------------------------------------------------------------
# Anthropic-compatible endpoint detection + image block conversion
# ---------------------------------------------------------------------------

# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper).
# Their image content blocks must use Anthropic format, not OpenAI format.
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-cn"})


def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Detect if an endpoint expects Anthropic-format content blocks.

Returns True for known Anthropic-compatible providers (MiniMax) and
any endpoint whose URL contains ``/anthropic`` in the path.
"""
if provider in _ANTHROPIC_COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower


def _convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.

Only touches messages that have list-type content with ``image_url`` blocks;
plain text messages pass through unchanged.
"""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
# Parse data URI: data:<media_type>;base64,<data>
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
# URL-based image
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted



def _build_call_kwargs(
provider: str,
model: str,
Expand Down Expand Up @@ -2149,6 +2265,7 @@ def call_llm(
model: str = None,
base_url: str = None,
api_key: str = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: float = None,
max_tokens: int = None,
Expand Down Expand Up @@ -2214,6 +2331,7 @@ def call_llm(
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=resolved_api_mode,
main_runtime=main_runtime,
)
if client is None:
# When the user explicitly chose a non-OpenRouter provider but no
Expand All @@ -2234,7 +2352,7 @@ def call_llm(
if not resolved_base_url:
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
task or "call", resolved_provider)
client, final_model = _get_cached_client("auto")
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
if client is None:
raise RuntimeError(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
Expand All @@ -2255,6 +2373,11 @@ def call_llm(
tools=tools, timeout=effective_timeout, extra_body=extra_body,
base_url=resolved_base_url)

# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])

# Handle max_tokens vs max_completion_tokens retry, then payment fallback.
try:
return _validate_llm_response(
Expand Down Expand Up @@ -2331,9 +2454,9 @@ def extract_content_or_reasoning(response) -> str:
if content:
# Strip inline think/reasoning blocks (mirrors _strip_think_blocks)
cleaned = re.sub(
r"<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>"
r"<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>"
r".*?"
r"</(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>",
r"</(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>",
"", content, flags=re.DOTALL | re.IGNORECASE,
).strip()
if cleaned:
Expand Down Expand Up @@ -2443,6 +2566,11 @@ async def async_call_llm(
tools=tools, timeout=effective_timeout, extra_body=extra_body,
base_url=resolved_base_url)

# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])

try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
Expand Down
Loading
Loading