Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions cmd/claw/hermes_base_image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
38 changes: 28 additions & 10 deletions cmd/claw/hermes_base_spike_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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},
"",
Expand Down
2 changes: 1 addition & 1 deletion dockerfiles/hermes-base/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}" \
Expand Down
441 changes: 318 additions & 123 deletions dockerfiles/hermes-base/patch-hermes-runtime.py

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions docs/plans/2026-06-23-next-slice-hermes-governance.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions internal/driver/hermes/baseimage.go
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions internal/driver/hermes/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -378,6 +383,7 @@ func allowedEnvPassthroughKeys() []string {
"GATEWAY_ALLOW_ALL_USERS",
hermesGatewayLockDirEnv,
hermesAllowSilentFinalEnv,
hermesChatStatusDeliveryEnv,
hermesToolProgressModeEnv,
hermesMemoryIndexMaxCharsEnv,
hermesUserMemoryMaxCharsEnv,
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions internal/driver/hermes/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`,
},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions internal/driver/hermes/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions internal/driver/hermes/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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}}
Expand Down
2 changes: 1 addition & 1 deletion internal/infraimages/release_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion site/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ outline: deep

## Unreleased

<!-- Nothing yet -->
- **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 <Badge type="tip" text="Latest" /> {#v0-23-1}

Expand Down
Loading
Loading