From b54c1ac6d7ba1854fa648f9d4cbccb88e2e672bd Mon Sep 17 00:00:00 2001 From: Wojtek Date: Tue, 23 Jun 2026 14:19:54 -0400 Subject: [PATCH] fix: suppress Hermes runtime status in chat --- cmd/claw/hermes_base_image_test.go | 2 + cmd/claw/hermes_base_spike_test.go | 38 +- dockerfiles/hermes-base/Dockerfile | 2 +- .../hermes-base/patch-hermes-runtime.py | 441 +++++++++++++----- ...2026-06-23-next-slice-hermes-governance.md | 87 ++++ internal/driver/hermes/baseimage.go | 4 +- internal/driver/hermes/config.go | 14 +- internal/driver/hermes/config_test.go | 48 ++ internal/driver/hermes/driver.go | 10 + internal/driver/hermes/driver_test.go | 33 ++ internal/infraimages/release_manifest.go | 2 +- site/changelog.md | 3 +- site/guide/drivers.md | 8 + site/guide/hermes.md | 13 +- 14 files changed, 564 insertions(+), 141 deletions(-) create mode 100644 docs/plans/2026-06-23-next-slice-hermes-governance.md diff --git a/cmd/claw/hermes_base_image_test.go b/cmd/claw/hermes_base_image_test.go index f91b01e..3f7673b 100644 --- a/cmd/claw/hermes_base_image_test.go +++ b/cmd/claw/hermes_base_image_test.go @@ -51,6 +51,8 @@ func TestHermesBaseImageSourceContract(t *testing.T) { `not stored_prompt.startswith(default_identity)`, `shutil.copy("/tmp/minisweagent_path.py", purelib / "minisweagent_path.py")`, `HERMES_TOOL_ONLY_MODE`, + `HERMES_CHAT_STATUS_DELIVERY`, + `gateway run managed chat status delivery gate`, `_claw_turn_sent_message`, `Suppressing duplicate final text after send_message`, `_already_used_tools_this_turn`, diff --git a/cmd/claw/hermes_base_spike_test.go b/cmd/claw/hermes_base_spike_test.go index 715eec4..b020fb6 100644 --- a/cmd/claw/hermes_base_spike_test.go +++ b/cmd/claw/hermes_base_spike_test.go @@ -34,6 +34,7 @@ python - <<'PY' import importlib.util import inspect import os +from pathlib import Path os.environ["HERMES_DEFAULT_AGENT_IDENTITY"] = "Clawdapus identity probe" os.environ["CLAWDAPUS_DISABLED_TOOLS"] = "text_to_speech" @@ -69,7 +70,6 @@ assert "session_search" in SESSION_SEARCH_GUIDANCE assert "skill_manage" in SKILLS_GUIDANCE from run_agent import AIAgent -import run_agent agent = AIAgent( base_url="http://127.0.0.1:9/v1", api_key="test", @@ -84,15 +84,23 @@ prompt = agent._build_system_prompt() assert prompt.startswith("Clawdapus identity probe"), prompt[:200] assert not prompt.startswith("You are Hermes Agent"), prompt[:200] -source = inspect.getsource(run_agent.AIAgent) -assert "_already_used_tools_this_turn" in source -assert "api_messages[_last_user_index + 1 :]" in source -assert "HERMES_ALLOW_SILENT_FINAL" in source -assert "Silent final enabled; treating empty visible response as completed no-op" in source -assert '"completed": True' in source -assert source.index('os.getenv("HERMES_ALLOW_SILENT_FINAL") == "1"') < source.index("Model returned empty after tool calls") -assert "X-Claw-Consumer-Session-Epoch" in source -assert "CLLAMA_CONSUMER_SESSION_EPOCH" in source +import agent.agent_runtime_helpers as runtime_helpers +import agent.chat_completion_helpers as chat_completion_helpers +import agent.conversation_loop as conversation_loop + +tool_source = Path(chat_completion_helpers.__file__).read_text() +assert "_already_used_tools_this_turn" in tool_source +assert "api_messages[_last_user_index + 1 :]" in tool_source + +silent_source = Path(conversation_loop.__file__).read_text() +assert "HERMES_ALLOW_SILENT_FINAL" in silent_source +assert "Silent final enabled; treating empty visible response as completed no-op" in silent_source +assert '"completed": True' in silent_source +assert silent_source.index('os.getenv("HERMES_ALLOW_SILENT_FINAL") == "1"') < silent_source.index("Model returned empty after tool calls") + +runtime_source = inspect.getsource(runtime_helpers.create_openai_client) +assert "X-Claw-Consumer-Session-Epoch" in runtime_source +assert "CLLAMA_CONSUMER_SESSION_EPOCH" in runtime_source epoch_agent = AIAgent( base_url="http://cllama:8080/v1", @@ -108,6 +116,16 @@ headers = {str(k).lower(): v for k, v in dict(getattr(epoch_agent.client, "defau assert headers.get("x-claw-consumer-session-epoch") == "epoch-contract", headers from gateway.run import GatewayRunner, _normalize_empty_agent_response +from gateway.config import Platform +from gateway.run import _prepare_gateway_status_message +os.environ.pop("HERMES_CHAT_STATUS_DELIVERY", None) +assert _prepare_gateway_status_message(Platform.DISCORD, "lifecycle", "Retrying in 1s") == "Retrying in 1s" +os.environ["HERMES_CHAT_STATUS_DELIVERY"] = "off" +assert _prepare_gateway_status_message(Platform.DISCORD, "lifecycle", "Retrying in 1s") is None +assert _prepare_gateway_status_message(Platform.SLACK, "warn", "Auxiliary title generation failed") is None +os.environ["HERMES_CHAT_STATUS_DELIVERY"] = "on" +assert _prepare_gateway_status_message(Platform.DISCORD, "lifecycle", "Retrying in 1s") == "Retrying in 1s" +del os.environ["HERMES_CHAT_STATUS_DELIVERY"] assert _normalize_empty_agent_response( {"api_calls": 1, "completed": True, "partial": False}, "", diff --git a/dockerfiles/hermes-base/Dockerfile b/dockerfiles/hermes-base/Dockerfile index 3391c48..95d5c54 100644 --- a/dockerfiles/hermes-base/Dockerfile +++ b/dockerfiles/hermes-base/Dockerfile @@ -3,7 +3,7 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim ARG GIT_REVISION=unknown ARG CLAW_SOURCE=local-checkout ARG CLAW_DIRTY=true -ARG HERMES_UPSTREAM_TAG=v2026.5.16 +ARG HERMES_UPSTREAM_TAG=v2026.6.19 LABEL claw.component="hermes-base" \ org.opencontainers.image.revision="${GIT_REVISION}" \ diff --git a/dockerfiles/hermes-base/patch-hermes-runtime.py b/dockerfiles/hermes-base/patch-hermes-runtime.py index 36984a7..2855ad0 100644 --- a/dockerfiles/hermes-base/patch-hermes-runtime.py +++ b/dockerfiles/hermes-base/patch-hermes-runtime.py @@ -22,6 +22,22 @@ def replace_once(text: str, old: str, new: str, label: str) -> str: raise SystemExit(f"expected to patch {label}") return text.replace(old, new, 1) + +def replace_once_any(text: str, options: list[tuple[str, str]], label: str) -> str: + for old, new in options: + if old in text: + return text.replace(old, new, 1) + raise SystemExit(f"expected to patch {label}") + + +def first_existing(*relative_paths: str) -> pathlib.Path: + for relative_path in relative_paths: + candidate = purelib / relative_path + if candidate.exists(): + return candidate + joined = ", ".join(relative_paths) + raise SystemExit(f"expected one installed Hermes file to exist: {joined}") + shutil.copy("/tmp/minisweagent_path.py", purelib / "minisweagent_path.py") # Replace only the first identity layer. Hermes memory/session/skill guidance @@ -59,7 +75,10 @@ def replace_once(text: str, old: str, new: str, label: str) -> str: # requires elevated bot privileges. members is conditional upstream now and # resolves to False for our pods (numeric DISCORD_ALLOWED_USERS, no roles), # so the previous unconditional False patch is obsolete and dropped. -discord_adapter = purelib / "gateway" / "platforms" / "discord.py" +discord_adapter = first_existing( + "plugins/platforms/discord/adapter.py", + "gateway/platforms/discord.py", +) text = discord_adapter.read_text() text = replace_once( text, @@ -81,14 +100,26 @@ def replace_once(text: str, old: str, new: str, label: str) -> str: # plain final answers are not silently lost. base_adapter = purelib / "gateway" / "platforms" / "base.py" text = base_adapter.read_text() -text = replace_once( +text = replace_once_any( text, - " # Send the text portion\n if text_content:\n", - " # Send the text portion. In HERMES_TOOL_ONLY_MODE, run.py\n" - " # clears this text only when the current turn already sent\n" - " # a visible message via send_message; otherwise this is the\n" - " # fallback that prevents final answers from disappearing.\n" - " if text_content:\n", + [ + ( + " # Send the text portion\n if text_content:\n", + " # Send the text portion. In HERMES_TOOL_ONLY_MODE, run.py\n" + " # clears this text only when the current turn already sent\n" + " # a visible message via send_message; otherwise this is the\n" + " # fallback that prevents final answers from disappearing.\n" + " if text_content:\n", + ), + ( + " # Send the text portion\n if text_content and not _tts_caption_delivered:\n", + " # Send the text portion. In HERMES_TOOL_ONLY_MODE, run.py\n" + " # clears this text only when the current turn already sent\n" + " # a visible message via send_message; otherwise this is the\n" + " # fallback that prevents final answers from disappearing.\n" + " if text_content and not _tts_caption_delivered:\n", + ), + ], "base platform tool-only mode fallback delivery", ) base_adapter.write_text(text) @@ -155,31 +186,7 @@ def _claw_filter_tools(tools): memory_tool = purelib / "tools" / "memory_tool.py" text = memory_tool.read_text() -text = replace_once( - text, - ''' # Calculate what the new total would be - new_entries = entries + [content] - new_total = len(ENTRY_DELIMITER.join(new_entries)) - - if new_total > limit: - current = self._char_count(target) - return { - "success": False, - "error": ( - f"Memory at {current:,}/{limit:,} chars. " - f"Adding this entry ({len(content)} chars) would exceed the limit. " - f"Replace or remove existing entries first." - ), - "current_entries": entries, - "usage": f"{current:,}/{limit:,}", - } - - entries.append(content) - self._set_entries(target, entries) - self.save_to_disk(target) - - return self._success_response(target, "Entry added.")''', - ''' # Calculate what the new total would be. If the new entry can +memory_add_eviction = ''' # Calculate what the new total would be. If the new entry can # fit by evicting older entries, evict oldest-first instead of # freezing memory writes at the cap. new_entries = entries + [content] @@ -229,7 +236,57 @@ def _claw_filter_tools(tools): f"Entry added. Evicted {len(evicted_entries)} oldest " f"{'entry' if len(evicted_entries) == 1 else 'entries'} to stay within the memory limit." ) - return response''', + return response''' +text = replace_once_any( + text, + [ + (''' # Calculate what the new total would be + new_entries = entries + [content] + new_total = len(ENTRY_DELIMITER.join(new_entries)) + + if new_total > limit: + current = self._char_count(target) + return { + "success": False, + "error": ( + f"Memory at {current:,}/{limit:,} chars. " + f"Adding this entry ({len(content)} chars) would exceed the limit. " + f"Replace or remove existing entries first." + ), + "current_entries": entries, + "usage": f"{current:,}/{limit:,}", + } + + entries.append(content) + self._set_entries(target, entries) + self.save_to_disk(target) + + return self._success_response(target, "Entry added.")''', memory_add_eviction), + (''' # Calculate what the new total would be + new_entries = entries + [content] + new_total = len(ENTRY_DELIMITER.join(new_entries)) + + if new_total > limit: + current = self._char_count(target) + return { + "success": False, + "error": ( + f"Memory at {current:,}/{limit:,} chars. " + f"Adding this entry ({len(content)} chars) would exceed the limit. " + f"Consolidate now: use 'replace' to merge overlapping entries into " + f"shorter ones or 'remove' stale or less important entries (see " + f"current_entries below), then retry this add — all in this turn." + ), + "current_entries": entries, + "usage": f"{current:,}/{limit:,}", + } + + entries.append(content) + self._set_entries(target, entries) + self.save_to_disk(target) + + return self._success_response(target, "Entry added.")''', memory_add_eviction), + ], "Hermes memory add oldest-entry eviction", ) text = replace_once( @@ -284,13 +341,7 @@ def _claw_filter_tools(tools): "def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Optional[str]:\n", "cron scheduler transient failure delivery classifier", ) -text = replace_once( - text, - " # Deliver the final response to the origin/target chat.\n" - " # If the agent responded with [SILENT], skip delivery (but\n" - " # output is already saved above). Failed jobs always deliver.\n" - " deliver_content = final_response if success else f\"⚠️ Cron job '{job.get('name', job['id'])}' failed:\\n{error}\"\n" - " should_deliver = bool(deliver_content)\n", +cron_suppress_old = ( " # Deliver the final response to the origin/target chat.\n" " # If the agent responded with [SILENT], skip delivery (but\n" " # output is already saved above). Failed jobs deliver unless\n" @@ -303,41 +354,97 @@ def _claw_filter_tools(tools): " else:\n" " logger.warning(\"Job '%s': suppressing transient cron failure delivery: %s\", job[\"id\"], error)\n" " deliver_content = None\n" - " should_deliver = bool(deliver_content)\n", + " should_deliver = bool(deliver_content)\n" +) +cron_suppress_current = ( + " # Deliver the final response to the origin/target chat.\n" + " # If the agent responded with [SILENT], skip delivery (but\n" + " # output is already saved above). Failed jobs deliver unless\n" + " # the failure is a transient provider/cllama outage; those are\n" + " # recorded in job state/logs without becoming channel noise.\n" + " if success:\n" + " deliver_content = final_response\n" + " elif _claw_should_deliver_cron_failure(error):\n" + " deliver_content = _summarize_cron_failure_for_delivery(job, error)\n" + " else:\n" + " logger.warning(\"Job '%s': suppressing transient cron failure delivery: %s\", job[\"id\"], error)\n" + " deliver_content = \"\"\n" +) +text = replace_once_any( + text, + [ + ( + " # Deliver the final response to the origin/target chat.\n" + " # If the agent responded with [SILENT], skip delivery (but\n" + " # output is already saved above). Failed jobs always deliver.\n" + " deliver_content = final_response if success else f\"⚠️ Cron job '{job.get('name', job['id'])}' failed:\\n{error}\"\n" + " should_deliver = bool(deliver_content)\n", + cron_suppress_old, + ), + ( + " # Deliver the final response to the origin/target chat.\n" + " # If the agent responded with [SILENT], skip delivery (but\n" + " # output is already saved above). Failed jobs always deliver.\n" + " deliver_content = final_response if success else _summarize_cron_failure_for_delivery(job, error)\n", + cron_suppress_current, + ), + ], "cron scheduler suppress transient failure delivery", ) cron_scheduler.write_text(text) -run_agent = purelib / "run_agent.py" -text = run_agent.read_text() - # cllama consumer session epoch: when Hermes is routed through the in-pod # cllama OpenAI-compatible base URL, attach the process-stable restart epoch # generated by entrypoint.sh. Keep this at the Hermes client factory layer so # provider-specific transport kwargs keep working unchanged. -text = replace_once( +runtime_helpers = first_existing("agent/agent_runtime_helpers.py", "run_agent.py") +text = runtime_helpers.read_text() +if "import os\n" not in text: + text = replace_once( + text, + "import logging\n", + "import logging\nimport os\n", + "run_agent runtime helpers os import", + ) +text = replace_once_any( text, - " client_kwargs = dict(client_kwargs)\n" - " _validate_proxy_env_urls()\n" - " _validate_base_url(client_kwargs.get(\"base_url\"))\n", - " client_kwargs = dict(client_kwargs)\n" - " _claw_epoch = os.getenv(\"CLLAMA_CONSUMER_SESSION_EPOCH\", \"\").strip()\n" - " _claw_base_host = base_url_hostname(str(client_kwargs.get(\"base_url\", \"\") or \"\"))\n" - " if _claw_epoch and (_claw_base_host == \"cllama\" or _claw_base_host.startswith(\"cllama-\")):\n" - " _claw_headers = dict(client_kwargs.get(\"default_headers\") or {})\n" - " _claw_headers[\"X-Claw-Consumer-Session-Epoch\"] = _claw_epoch\n" - " client_kwargs[\"default_headers\"] = _claw_headers\n" - " _validate_proxy_env_urls()\n" - " _validate_base_url(client_kwargs.get(\"base_url\"))\n", + [ + ( + " client_kwargs = dict(client_kwargs)\n" + " _validate_proxy_env_urls()\n" + " _validate_base_url(client_kwargs.get(\"base_url\"))\n", + " client_kwargs = dict(client_kwargs)\n" + " _claw_epoch = os.getenv(\"CLLAMA_CONSUMER_SESSION_EPOCH\", \"\").strip()\n" + " _claw_base_host = base_url_hostname(str(client_kwargs.get(\"base_url\", \"\") or \"\"))\n" + " if _claw_epoch and (_claw_base_host == \"cllama\" or _claw_base_host.startswith(\"cllama-\")):\n" + " _claw_headers = dict(client_kwargs.get(\"default_headers\") or {})\n" + " _claw_headers[\"X-Claw-Consumer-Session-Epoch\"] = _claw_epoch\n" + " client_kwargs[\"default_headers\"] = _claw_headers\n" + " _validate_proxy_env_urls()\n" + " _validate_base_url(client_kwargs.get(\"base_url\"))\n", + ), + ( + " client_kwargs = dict(client_kwargs)\n" + " _validate_proxy_env_urls()\n" + " _validate_base_url(client_kwargs.get(\"base_url\"))\n", + " client_kwargs = dict(client_kwargs)\n" + " _claw_epoch = os.getenv(\"CLLAMA_CONSUMER_SESSION_EPOCH\", \"\").strip()\n" + " _claw_base_host = base_url_hostname(str(client_kwargs.get(\"base_url\", \"\") or \"\"))\n" + " if _claw_epoch and (_claw_base_host == \"cllama\" or _claw_base_host.startswith(\"cllama-\")):\n" + " _claw_headers = dict(client_kwargs.get(\"default_headers\") or {})\n" + " _claw_headers[\"X-Claw-Consumer-Session-Epoch\"] = _claw_epoch\n" + " client_kwargs[\"default_headers\"] = _claw_headers\n" + " _validate_proxy_env_urls()\n" + " _validate_base_url(client_kwargs.get(\"base_url\"))\n", + ), + ], "run_agent cllama consumer session epoch header", ) +runtime_helpers.write_text(text) -text = replace_once( - text, - " self._memory_store = MemoryStore(\n" - " memory_char_limit=mem_config.get(\"memory_char_limit\", 2200),\n" - " user_char_limit=mem_config.get(\"user_char_limit\", 1375),\n" - " )\n", +agent_init = first_existing("agent/agent_init.py", "run_agent.py") +text = agent_init.read_text() +memory_limit_patch_self = ( " def _claw_memory_limit(env_name: str, configured, fallback: int) -> int:\n" " raw = os.getenv(env_name, \"\").strip()\n" " value = raw if raw else configured\n" @@ -358,80 +465,147 @@ def _claw_filter_tools(tools): " mem_config.get(\"user_char_limit\", 6000),\n" " 6000,\n" " ),\n" - " )\n", + " )\n" +) +memory_limit_patch_agent = ( + " def _claw_memory_limit(env_name: str, configured, fallback: int) -> int:\n" + " raw = os.getenv(env_name, \"\").strip()\n" + " value = raw if raw else configured\n" + " try:\n" + " parsed = int(value)\n" + " except (TypeError, ValueError):\n" + " parsed = fallback\n" + " return parsed if parsed > 0 else fallback\n" + "\n" + " agent._memory_store = MemoryStore(\n" + " memory_char_limit=_claw_memory_limit(\n" + " \"HERMES_MEMORY_INDEX_MAX_CHARS\",\n" + " mem_config.get(\"memory_char_limit\", 12000),\n" + " 12000,\n" + " ),\n" + " user_char_limit=_claw_memory_limit(\n" + " \"HERMES_USER_MEMORY_MAX_CHARS\",\n" + " mem_config.get(\"user_char_limit\", 6000),\n" + " 6000,\n" + " ),\n" + " )\n" +) +text = replace_once_any( + text, + [ + ( + " self._memory_store = MemoryStore(\n" + " memory_char_limit=mem_config.get(\"memory_char_limit\", 2200),\n" + " user_char_limit=mem_config.get(\"user_char_limit\", 1375),\n" + " )\n", + memory_limit_patch_self, + ), + ( + " agent._memory_store = MemoryStore(\n" + " memory_char_limit=mem_config.get(\"memory_char_limit\", 2200),\n" + " user_char_limit=mem_config.get(\"user_char_limit\", 1375),\n" + " )\n", + memory_limit_patch_agent, + ), + ], "run_agent Hermes memory env-configurable limits", ) +agent_init.write_text(text) # Continuing gateway sessions persist a system_prompt snapshot. If the managed # identity changes, rebuild the prompt instead of reusing a stale Hermes identity. -text = replace_once( +conversation_loop = first_existing("agent/conversation_loop.py", "run_agent.py") +text = conversation_loop.read_text() +text = replace_once_any( text, - ' stored_prompt = session_row.get("system_prompt") or None\n', - ' stored_prompt = session_row.get("system_prompt") or None\n' - ' default_identity = os.getenv("HERMES_DEFAULT_AGENT_IDENTITY", "").strip()\n' - ' if stored_prompt and default_identity and not stored_prompt.startswith(default_identity):\n' - ' logger.info("Refreshing stored system prompt because HERMES_DEFAULT_AGENT_IDENTITY changed")\n' - ' stored_prompt = None\n', + [ + ( + ' stored_prompt = session_row.get("system_prompt") or None\n', + ' stored_prompt = session_row.get("system_prompt") or None\n' + ' default_identity = os.getenv("HERMES_DEFAULT_AGENT_IDENTITY", "").strip()\n' + ' if stored_prompt and default_identity and not stored_prompt.startswith(default_identity):\n' + ' logger.info("Refreshing stored system prompt because HERMES_DEFAULT_AGENT_IDENTITY changed")\n' + ' stored_prompt = None\n', + ), + ( + ' stored_prompt = raw_prompt\n' + ' stored_state = "present"\n', + ' stored_prompt = raw_prompt\n' + ' default_identity = os.getenv("HERMES_DEFAULT_AGENT_IDENTITY", "").strip()\n' + ' if stored_prompt and default_identity and not stored_prompt.startswith(default_identity):\n' + ' logger.info("Refreshing stored system prompt because HERMES_DEFAULT_AGENT_IDENTITY changed")\n' + ' stored_prompt = None\n' + ' stored_state = "stale_identity"\n' + ' else:\n' + ' stored_state = "present"\n', + ), + ], "run_agent stored prompt identity invalidation", ) +conversation_loop.write_text(text) # Tool-only mode: force tool_choice=required per user turn. # Upstream now has a provider-profile path and a legacy fallback inside -# `_build_api_kwargs`. Capture the chat-completions kwargs in both paths and +# `build_api_kwargs`. Capture the chat-completions kwargs in both paths and # inject `tool_choice="required"` at the start of each user turn so the model # must call a tool (preferably send_message) before falling back to plain text. +chat_completion_helpers = first_existing("agent/chat_completion_helpers.py", "run_agent.py") +text = chat_completion_helpers.read_text() text = replace_once( text, - " return _ct.build_kwargs(\n" - " model=self.model,\n" - " messages=api_messages,\n" - " tools=tools_for_api,\n" - " base_url=self.base_url,\n", - " _claw_kwargs = _ct.build_kwargs(\n" - " model=self.model,\n" - " messages=api_messages,\n" - " tools=tools_for_api,\n" - " base_url=self.base_url,\n", + " return _ct.build_kwargs(\n" + " model=agent.model,\n" + " messages=api_messages,\n" + " tools=tools_for_api,\n" + " base_url=agent.base_url,\n", + " _claw_kwargs = _ct.build_kwargs(\n" + " model=agent.model,\n" + " messages=api_messages,\n" + " tools=tools_for_api,\n" + " base_url=agent.base_url,\n", "run_agent provider-profile _build_api_kwargs capture for tool-only mode", ) text = replace_once( text, - " qwen_session_metadata=_qwen_meta,\n" - " )\n" + " qwen_session_metadata=_qwen_meta,\n" + " )\n" "\n" - " # ── Legacy flag path", - " qwen_session_metadata=_qwen_meta,\n" - " )\n" - " return self._claw_apply_tool_only_choice(_claw_kwargs, api_messages)\n" + " # ── Legacy flag path", + " qwen_session_metadata=_qwen_meta,\n" + " )\n" + " return _claw_apply_tool_only_choice(agent, _claw_kwargs, api_messages)\n" "\n" - " # ── Legacy flag path", + " # ── Legacy flag path", "run_agent provider-profile tool-only mode return", ) text = replace_once( text, - " return _ct.build_kwargs(\n" - " model=self.model,\n" - " messages=_msgs_for_chat,\n" - " tools=tools_for_api,\n" - " base_url=self.base_url,\n", - " _claw_kwargs = _ct.build_kwargs(\n" - " model=self.model,\n" - " messages=_msgs_for_chat,\n" - " tools=tools_for_api,\n" - " base_url=self.base_url,\n", + " return _ct.build_kwargs(\n" + " model=agent.model,\n" + " messages=_msgs_for_chat,\n" + " tools=tools_for_api,\n" + " base_url=agent.base_url,\n", + " _claw_kwargs = _ct.build_kwargs(\n" + " model=agent.model,\n" + " messages=_msgs_for_chat,\n" + " tools=tools_for_api,\n" + " base_url=agent.base_url,\n", "run_agent legacy _build_api_kwargs capture for tool-only mode", ) text = replace_once( text, - " provider_name=self.provider,\n" - " )\n" + " provider_name=agent.provider,\n" + " )\n" "\n" - " def _supports_reasoning_extra_body(self) -> bool:\n", - " provider_name=self.provider,\n" - " )\n" - " return self._claw_apply_tool_only_choice(_claw_kwargs, _msgs_for_chat)\n" "\n" - " def _claw_apply_tool_only_choice(self, _claw_kwargs: dict, api_messages: list) -> dict:\n" + "\n" + "def build_assistant_message(agent, assistant_message, finish_reason: str) -> dict:\n", + " provider_name=agent.provider,\n" + " )\n" + " return _claw_apply_tool_only_choice(agent, _claw_kwargs, _msgs_for_chat)\n" + "\n" + "\n" + "def _claw_apply_tool_only_choice(agent, _claw_kwargs: dict, api_messages: list) -> dict:\n" " # In tool-only mode, force tool_choice=required on the first LLM\n" " # call of each user turn. Inspect only messages after the latest\n" " # user message so prior turns' tool_calls do not satisfy this turn.\n" @@ -454,37 +628,58 @@ def _claw_filter_tools(tools): " _claw_kwargs[\"tool_choice\"] = \"required\"\n" " return _claw_kwargs\n" "\n" - " def _supports_reasoning_extra_body(self) -> bool:\n", + "\n" + "\n" + "def build_assistant_message(agent, assistant_message, finish_reason: str) -> dict:\n", "run_agent tool_choice=required per turn in tool-only mode", ) +chat_completion_helpers.write_text(text) # Silent-final opt-in: when HERMES_ALLOW_SILENT_FINAL=1, treat empty visible # final responses as a successful no-op turn instead of retrying/nudging. # Managed messaging agents often deliver the visible reply through send_message # and then intentionally have nothing else to say. +text = conversation_loop.read_text() text = replace_once( text, - ' if not self._has_content_after_think_block(final_response):\n', - ' if not self._has_content_after_think_block(final_response):\n' - ' if os.getenv("HERMES_ALLOW_SILENT_FINAL") == "1":\n' - ' logger.debug("Silent final enabled; treating empty visible response as completed no-op")\n' - ' self._empty_content_retries = 0\n' - ' self._cleanup_task_resources(effective_task_id)\n' - ' self._persist_session(messages, conversation_history)\n' - ' return {\n' - ' "final_response": None,\n' - ' "messages": messages,\n' - ' "api_calls": api_call_count,\n' - ' "completed": True,\n' - ' "partial": False,\n' - ' }\n', + ' if not agent._has_content_after_think_block(final_response):\n', + ' if not agent._has_content_after_think_block(final_response):\n' + ' if os.getenv("HERMES_ALLOW_SILENT_FINAL") == "1":\n' + ' logger.debug("Silent final enabled; treating empty visible response as completed no-op")\n' + ' agent._empty_content_retries = 0\n' + ' agent._cleanup_task_resources(effective_task_id)\n' + ' agent._persist_session(messages, conversation_history)\n' + ' return {\n' + ' "final_response": None,\n' + ' "messages": messages,\n' + ' "api_calls": api_call_count,\n' + ' "completed": True,\n' + ' "partial": False,\n' + ' }\n', "run_agent silent final opt-in", ) -run_agent.write_text(text) +conversation_loop.write_text(text) gateway_run = purelib / "gateway" / "run.py" text = gateway_run.read_text() +# Clawdapus-managed chat surfaces treat status_callback output as runtime +# telemetry. Keep upstream behavior when unset, but suppress chat delivery when +# HERMES_CHAT_STATUS_DELIVERY is explicitly false/off/0. +text = replace_once( + text, + " if not text:\n" + " return None\n" + " if _gateway_platform_value(platform) != \"telegram\":\n", + " if not text:\n" + " return None\n" + " _claw_status_delivery = os.getenv(\"HERMES_CHAT_STATUS_DELIVERY\", \"\").strip().lower()\n" + " if _claw_status_delivery and _claw_status_delivery not in {\"1\", \"true\", \"yes\", \"on\", \"chat\", \"visible\", \"all\"}:\n" + " return None\n" + " if _gateway_platform_value(platform) != \"telegram\":\n", + "gateway run managed chat status delivery gate", +) + # The run_agent.py patch above marks empty visible final responses as # successful completed no-op turns when HERMES_ALLOW_SILENT_FINAL=1. The # gateway has a second empty-response normalizer that otherwise rewrites that diff --git a/docs/plans/2026-06-23-next-slice-hermes-governance.md b/docs/plans/2026-06-23-next-slice-hermes-governance.md new file mode 100644 index 0000000..98d3d45 --- /dev/null +++ b/docs/plans/2026-06-23-next-slice-hermes-governance.md @@ -0,0 +1,87 @@ +# Next Slice: Hermes Image Refresh + Governance Enforcement (2026-06-23) + +**Coordination:** Talking Stick room `79e89703-8009-4c52-8663-1ae577a3dfcb`. +**Status:** Hermes image refresh and #257/#259 quiet-channel slice implemented; broader policy-plane work remains future work. + +## Current Ground Truth + +- Work is on branch `issue-257-259-hermes-status-noise`. +- Latest Clawdapus release before this slice is `v0.23.1`; it pins: + - `hermes-base:v2026.5.16-claw.3` + - `cllama:v0.7.3` + - first-party infra images at `v0.23.1` +- The three newly filed production reliability issues are closed: + - #317 Hermes `MEMORY.md` cap/eviction + - #318 Hermes memory file mode + - #319 cllama upstream failure handling +- Open Hermes work remains in #257/#259: runtime, retry, scheduler, auxiliary, and background-review status must not post into content channels by default. +- Upstream Hermes moved beyond the previous base: + - previous Clawdapus upstream pin: `v2026.5.16` + - latest upstream Hermes tag observed: `v2026.6.19` +- The patch ledger has been rebased onto `v2026.6.19`: + - Discord moved from `gateway/platforms/discord.py` to `plugins/platforms/discord/adapter.py` + - OpenAI client construction moved to `agent/agent_runtime_helpers.py` + - memory initialization moved to `agent/agent_init.py` + - silent-final handling moved to `agent/conversation_loop.py` + - tool-only kwargs handling moved to `agent/chat_completion_helpers.py` +- `hermes-base:v2026.6.19-claw.2` has been published as a multi-arch image. +- Project-board hygiene is now current: the previously missing open issues (#258, #259, #260, #261, + #312, #313, #314, #315) were added to the Clawdapus project and set to `Backlog`. + +## Recommendation + +Take the Hermes image opportunity, but treat it as a guarded Hermes train, not a blind pin bump. + +The right next release shape is: + +1. **Hermes train (#257/#259):** + - Rebase `dockerfiles/hermes-base/patch-hermes-runtime.py` against upstream `v2026.6.19`. + - Preserve existing Clawdapus patches: identity, voice intent trimming, tool-only final delivery, disabled tool filtering, memory cap/eviction/file mode, cron transient-failure suppression, silent-final behavior, cllama consumer session epoch. + - Add the #257/#259 default policy: runtime/retry/fallback/auxiliary/background-review status goes to logs/telemetry by default, not content channels; keep an opt-in debug path. + - Done in this slice with `HERMES_CHAT_STATUS_DELIVERY=off` by default and `display.memory_notifications: off`. +2. **Policy plane design (#306):** + - Write ADR-025 and the `docs/CLLAMA_SPEC.md` contract update before implementing generalized policy hooks. + - Keep #306 as the convergence gate for #307/#308. +3. **Budget enforcement (#310):** + - Implement core hard caps in cllama independently of the generalized policy plane if #306 converges. + - Prove with a full spike: exceed cap -> 429/intervention -> `fleet.budget.set` raises cap -> traffic resumes. +4. **Fleet governance demo (#309):** + - Conditional on #310 landing cleanly. It should demonstrate a real closed loop, not only a doc walkthrough. +5. **Docs/site freshness:** + - Update `docs/PROJECT_STATE.md`, `docs/CLLAMA_SPEC.md`, Hermes driver docs, the public site guide pages, and changelog `Unreleased`. + - Re-promote claims that were softened for #304 only where #310 makes enforcement real. +6. **Board hygiene:** + - Done for missing open issues. + - Move only the chosen slice to `In Progress`; do not reshuffle backlog priority without maintainer signoff. + +## Hermes Release Gates + +The Hermes pin must not move until the image exists. + +Required order: + +1. Rebase/patch `hermes-base`. Done. +2. Run the local canary build against `v2026.6.19`. Done. +3. Run `go test -tags spike -run TestSpikeHermesBaseImageContract ./cmd/claw`. Done. +4. Build and push a multi-arch image, for example: + + ```sh + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t ghcr.io/mostlydev/hermes-base:v2026.6.19-claw.1 \ + --push dockerfiles/hermes-base/ + ``` + +5. Verify the pushed manifest includes `linux/amd64` and `linux/arm64`. Done for `v2026.6.19-claw.2`. +6. Only then bump: + - `internal/driver/hermes/baseimage.go` + - `internal/infraimages/release_manifest.go` +7. Run release verification. Done with `go run ./scripts/check-release-infra-tags --release-tag v0.23.1`. +8. Cut the Clawdapus release through the normal release workflow after review. + +## Open Decisions + +- **Priority:** Hermes train ships before #306/#310; #306 ADR remains a separate convergence gate. +- **Upstream bump fallback:** Not needed; `v2026.6.19` rebase succeeded. +- **Default status policy:** Managed Hermes chat handles suppress runtime status by default; operators opt in with `HERMES_CHAT_STATUS_DELIVERY=on`. +- **Live spike:** If credentials are available, run a Discord/Hermes smoke after the image spike to prove provider-outage/status messages stay out of the content channel while real assistant replies still deliver. diff --git a/internal/driver/hermes/baseimage.go b/internal/driver/hermes/baseimage.go index 66df9cf..b2e49e6 100644 --- a/internal/driver/hermes/baseimage.go +++ b/internal/driver/hermes/baseimage.go @@ -1,7 +1,7 @@ package hermes -const UpstreamTag = "v2026.5.16" +const UpstreamTag = "v2026.6.19" -const BaseImageVersion = UpstreamTag + "-claw.3" +const BaseImageVersion = UpstreamTag + "-claw.2" var BaseImageTag = "ghcr.io/mostlydev/hermes-base:" + BaseImageVersion diff --git a/internal/driver/hermes/config.go b/internal/driver/hermes/config.go index 42345da..b19f46d 100644 --- a/internal/driver/hermes/config.go +++ b/internal/driver/hermes/config.go @@ -21,6 +21,7 @@ const ( hermesDefaultAgentIdentityEnv = "HERMES_DEFAULT_AGENT_IDENTITY" hermesGatewayLockDirEnv = "HERMES_GATEWAY_LOCK_DIR" hermesAllowSilentFinalEnv = "HERMES_ALLOW_SILENT_FINAL" + hermesChatStatusDeliveryEnv = "HERMES_CHAT_STATUS_DELIVERY" hermesToolProgressModeEnv = "HERMES_TOOL_PROGRESS_MODE" hermesMemoryIndexMaxCharsEnv = "HERMES_MEMORY_INDEX_MAX_CHARS" hermesUserMemoryMaxCharsEnv = "HERMES_USER_MEMORY_MAX_CHARS" @@ -58,8 +59,9 @@ func GenerateConfig(rc *driver.ResolvedClaw, modelCfg *modelConfig) ([]byte, err "wrap_response": false, }, "display": map[string]any{ - "busy_input_mode": hermesDefaultBusyInputMode, - "busy_ack_enabled": false, + "busy_input_mode": hermesDefaultBusyInputMode, + "busy_ack_enabled": false, + "memory_notifications": "off", }, "model": modelBlock, "onboarding": map[string]any{ @@ -155,6 +157,9 @@ func GenerateEnvFile(rc *driver.ResolvedClaw, modelCfg *modelConfig) ([]byte, er env[hermesAllowSilentFinalEnv] = "1" env[hermesToolProgressModeEnv] = "off" } + if hasManagedChatHandle(rc) { + env[hermesChatStatusDeliveryEnv] = "off" + } if hasDiscordHandle(rc) { // Hermes upstream defaults reply-mention pings to True // (DISCORD_ALLOW_MENTION_REPLIED_USER), which produces mention loops in @@ -378,6 +383,7 @@ func allowedEnvPassthroughKeys() []string { "GATEWAY_ALLOW_ALL_USERS", hermesGatewayLockDirEnv, hermesAllowSilentFinalEnv, + hermesChatStatusDeliveryEnv, hermesToolProgressModeEnv, hermesMemoryIndexMaxCharsEnv, hermesUserMemoryMaxCharsEnv, @@ -411,6 +417,10 @@ func hasSlackHandle(rc *driver.ResolvedClaw) bool { return hasHandle(rc, "slack") } +func hasManagedChatHandle(rc *driver.ResolvedClaw) bool { + return hasDiscordHandle(rc) || hasSlackHandle(rc) || hasHandle(rc, "telegram") +} + func hasHandle(rc *driver.ResolvedClaw, platform string) bool { if rc == nil { return false diff --git a/internal/driver/hermes/config_test.go b/internal/driver/hermes/config_test.go index 114bf3e..fea176b 100644 --- a/internal/driver/hermes/config_test.go +++ b/internal/driver/hermes/config_test.go @@ -366,6 +366,9 @@ func TestGenerateConfigDefaultsManagedGatewayUXQuiet(t *testing.T) { if got := display["busy_ack_enabled"]; got != false { t.Fatalf("expected display.busy_ack_enabled=false, got %#v", got) } + if got := display["memory_notifications"]; got != "off" { + t.Fatalf("expected display.memory_notifications=off, got %#v", got) + } onboarding, _ := cfg["onboarding"].(map[string]any) seen, _ := onboarding["seen"].(map[string]any) @@ -394,6 +397,7 @@ func TestGenerateConfigAllowsManagedGatewayUXOptIn(t *testing.T) { `hermes config set --json cron.wrap_response true`, `hermes config set display.busy_input_mode interrupt`, `hermes config set --json display.busy_ack_enabled true`, + `hermes config set display.memory_notifications verbose`, `hermes config set --json onboarding.seen.busy_input_prompt false`, `hermes config set --json platforms.discord.gateway_restart_notification true`, }, @@ -435,6 +439,9 @@ func TestGenerateConfigAllowsManagedGatewayUXOptIn(t *testing.T) { if got := display["busy_ack_enabled"]; got != true { t.Fatalf("expected display.busy_ack_enabled override, got %#v", got) } + if got := display["memory_notifications"]; got != "verbose" { + t.Fatalf("expected display.memory_notifications override, got %#v", got) + } onboarding, _ := cfg["onboarding"].(map[string]any) seen, _ := onboarding["seen"].(map[string]any) if got := seen["busy_input_prompt"]; got != false { @@ -553,6 +560,9 @@ func TestGenerateEnvFileDefaultsToolProgressOffForDiscord(t *testing.T) { if !strings.Contains(string(data), hermesAllowSilentFinalEnv+"=1\n") { t.Fatalf("expected %s=1 in .env, got:\n%s", hermesAllowSilentFinalEnv, data) } + if !strings.Contains(string(data), hermesChatStatusDeliveryEnv+"=off\n") { + t.Fatalf("expected %s=off in .env, got:\n%s", hermesChatStatusDeliveryEnv, data) + } } func TestGenerateEnvFileDefaultsToolProgressOffForSlack(t *testing.T) { @@ -570,6 +580,44 @@ func TestGenerateEnvFileDefaultsToolProgressOffForSlack(t *testing.T) { if !strings.Contains(string(data), hermesAllowSilentFinalEnv+"=1\n") { t.Fatalf("expected %s=1 in .env, got:\n%s", hermesAllowSilentFinalEnv, data) } + if !strings.Contains(string(data), hermesChatStatusDeliveryEnv+"=off\n") { + t.Fatalf("expected %s=off in .env, got:\n%s", hermesChatStatusDeliveryEnv, data) + } +} + +func TestGenerateEnvFileDefaultsChatStatusDeliveryOffForTelegram(t *testing.T) { + rc := &driver.ResolvedClaw{ + Handles: map[string]*driver.HandleInfo{"telegram": {}}, + } + data, err := GenerateEnvFile(rc, &modelConfig{Env: map[string]string{}}) + if err != nil { + t.Fatalf("GenerateEnvFile returned error: %v", err) + } + + if !strings.Contains(string(data), hermesChatStatusDeliveryEnv+"=off\n") { + t.Fatalf("expected %s=off in .env, got:\n%s", hermesChatStatusDeliveryEnv, data) + } +} + +func TestGenerateEnvFileAllowsChatStatusDeliveryOverride(t *testing.T) { + rc := &driver.ResolvedClaw{ + Handles: map[string]*driver.HandleInfo{"discord": {}}, + Environment: map[string]string{ + hermesChatStatusDeliveryEnv: "on", + }, + } + data, err := GenerateEnvFile(rc, &modelConfig{Env: map[string]string{}}) + if err != nil { + t.Fatalf("GenerateEnvFile returned error: %v", err) + } + + env := string(data) + if !strings.Contains(env, hermesChatStatusDeliveryEnv+"=on\n") { + t.Fatalf("expected %s override in .env, got:\n%s", hermesChatStatusDeliveryEnv, env) + } + if strings.Contains(env, hermesChatStatusDeliveryEnv+"=off\n") { + t.Fatalf("expected no default %s=off when override is set, got:\n%s", hermesChatStatusDeliveryEnv, env) + } } func TestGenerateEnvFileAllowsSilentFinalDefaultOverride(t *testing.T) { diff --git a/internal/driver/hermes/driver.go b/internal/driver/hermes/driver.go index acba029..f8dae23 100644 --- a/internal/driver/hermes/driver.go +++ b/internal/driver/hermes/driver.go @@ -211,6 +211,16 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt env[hermesToolProgressModeEnv] = value } } + if hasManagedChatHandle(rc) { + env[hermesChatStatusDeliveryEnv] = "off" + value, err := resolvedEnvValue(rc, hermesChatStatusDeliveryEnv) + if err != nil { + return nil, fmt.Errorf("hermes driver: %w", err) + } + if value != "" { + env[hermesChatStatusDeliveryEnv] = value + } + } value, err := resolvedEnvValue(rc, hermesAllowSilentFinalEnv) if err != nil { return nil, fmt.Errorf("hermes driver: %w", err) diff --git a/internal/driver/hermes/driver_test.go b/internal/driver/hermes/driver_test.go index 9e9ca06..20440c2 100644 --- a/internal/driver/hermes/driver_test.go +++ b/internal/driver/hermes/driver_test.go @@ -221,6 +221,9 @@ func TestMaterializeWritesRuntimeLayout(t *testing.T) { if got := result.Environment[hermesToolProgressModeEnv]; got != "off" { t.Fatalf("expected %s=off, got %q", hermesToolProgressModeEnv, got) } + if got := result.Environment[hermesChatStatusDeliveryEnv]; got != "off" { + t.Fatalf("expected %s=off, got %q", hermesChatStatusDeliveryEnv, got) + } if got := result.Environment[clawdapusDisabledToolsEnv]; got != hermesTextToSpeechTool { t.Fatalf("expected %s=%s, got %q", clawdapusDisabledToolsEnv, hermesTextToSpeechTool, got) } @@ -617,6 +620,36 @@ func TestMaterializeAllowsSilentFinalDefaultOverride(t *testing.T) { } } +func TestMaterializeAllowsChatStatusDeliveryOverride(t *testing.T) { + rc, tmp := newTestRC(t) + rc.Environment[hermesChatStatusDeliveryEnv] = "on" + runtimeDir := filepath.Join(tmp, "runtime") + if err := os.MkdirAll(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + + result, err := (&Driver{}).Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test"}) + if err != nil { + t.Fatalf("Materialize returned error: %v", err) + } + + if got := result.Environment[hermesChatStatusDeliveryEnv]; got != "on" { + t.Fatalf("expected %s override, got %q", hermesChatStatusDeliveryEnv, got) + } + + envData, err := os.ReadFile(filepath.Join(runtimeDir, "hermes-home", ".env")) + if err != nil { + t.Fatalf("read .env: %v", err) + } + env := string(envData) + if !strings.Contains(env, hermesChatStatusDeliveryEnv+"=on\n") { + t.Fatalf("expected %s override in .env, got:\n%s", hermesChatStatusDeliveryEnv, env) + } + if strings.Contains(env, hermesChatStatusDeliveryEnv+"=off\n") { + t.Fatalf("expected no default %s=off when override is set, got:\n%s", hermesChatStatusDeliveryEnv, env) + } +} + func TestMaterializeHonorsHermesAllowToolsOptIn(t *testing.T) { rc, tmp := newTestRC(t) rc.Hermes = &driver.HermesConfig{AllowTools: []string{hermesTextToSpeechTool}} diff --git a/internal/infraimages/release_manifest.go b/internal/infraimages/release_manifest.go index 35e7c11..35a4b80 100644 --- a/internal/infraimages/release_manifest.go +++ b/internal/infraimages/release_manifest.go @@ -10,7 +10,7 @@ const ( DefaultClawChannelMemoryTag = DefaultClawInfraTag DefaultClawMCPStdioTag = DefaultClawInfraTag DefaultCllamaTag = "v0.7.3" - DefaultHermesBaseTag = "v2026.5.16-claw.3" + DefaultHermesBaseTag = "v2026.6.19-claw.2" ) func ReleaseRefs(releaseTag string) []string { diff --git a/site/changelog.md b/site/changelog.md index 162b131..887fe37 100644 --- a/site/changelog.md +++ b/site/changelog.md @@ -29,7 +29,8 @@ outline: deep ## Unreleased - +- **Hermes runtime telemetry stays out of content channels** -- managed Hermes chat services now keep lifecycle, retry/fallback, provider-failure, and background-review status in logs by default instead of delivering it as channel prose. Operators can opt visible status back in with `HERMES_CHAT_STATUS_DELIVERY=on` and background-review summaries with `display.memory_notifications`. The `hermes-base` image refreshes to upstream Hermes `v2026.6.19` with the Clawdapus compatibility patches rebased onto the new module layout. Closes [#257](https://github.com/mostlydev/clawdapus/issues/257), [#259](https://github.com/mostlydev/clawdapus/issues/259). +- **Pins infra images** -- `hermes-base` moves to `v2026.6.19-claw.2`. ## v0.23.1 {#v0-23-1} diff --git a/site/guide/drivers.md b/site/guide/drivers.md index db2c0d1..7830622 100644 --- a/site/guide/drivers.md +++ b/site/guide/drivers.md @@ -85,6 +85,14 @@ failures are logged and recorded as failed runs without user-channel delivery by default; set `HERMES_CRON_DELIVER_TRANSIENT_FAILURES=1` to restore the old delivery behavior. +Managed Hermes chat services also set `HERMES_CHAT_STATUS_DELIVERY=off` and +`display.memory_notifications: off` by default. Retry/fallback/lifecycle status +callbacks and background-review summaries stay in logs instead of being posted +as channel prose; set `HERMES_CHAT_STATUS_DELIVERY=on` or configure +`display.memory_notifications` when an interactive/debug channel should show +that runtime telemetry. Unset `HERMES_CHAT_STATUS_DELIVERY` preserves upstream +Hermes behavior; the quiet default is applied by the Clawdapus driver. + ### nanoclaw NanoClaw / Claude Code-compatible orchestrator driver. It does not currently support HANDLE or INVOKE, and it requires `PRIVILEGE docker-socket true` because the runtime spawns agent containers through Docker. It has a structured health probe and uses a writable root filesystem. diff --git a/site/guide/hermes.md b/site/guide/hermes.md index 93ae622..10875c0 100644 --- a/site/guide/hermes.md +++ b/site/guide/hermes.md @@ -102,7 +102,18 @@ Hermes writes runner-owned memory into Clawdapus' portable memory surface. The m On Discord, Hermes prefers emitting `send_message` tool calls over plain text finals. Clawdapus configures silent-final handling so the agent doesn't double-post. If your agent "thinks but never replies", check whether it produced a final with no `send_message` — `claw logs assistant` shows the turn; the [troubleshooting guide](/guide/troubleshooting) has the full decision table. -### 5. gateway.log: the first diagnostic surface +### 5. Runtime status is not channel content + +Managed Hermes chat services keep lifecycle, retry/fallback, provider-failure, +and background-review status out of content channels by default. `claw logs +assistant` and `/root/.hermes/logs/gateway.log` remain the diagnostic surfaces. +For an interactive/debug service, opt visible status back in with +`HERMES_CHAT_STATUS_DELIVERY=on`; opt background-review summaries back in with +`hermes config set display.memory_notifications on`. Unset +`HERMES_CHAT_STATUS_DELIVERY` preserves upstream Hermes behavior; Clawdapus +sets it to `off` for managed chat services. + +### 6. gateway.log: the first diagnostic surface ```bash claw compose exec assistant cat /root/.hermes/logs/gateway.log