Skip to content
Closed
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
62 changes: 44 additions & 18 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,20 @@ def _detect_claude_code_version() -> str:
_MCP_TOOL_PREFIX = "mcp_"


def _oauth_mcp_prefix_enabled() -> bool:
"""Whether to apply the Claude-Code mcp_ tool-name convention on OAuth.

Default True (the historical behaviour). Set HERMES_OAUTH_NO_MCP_PREFIX=1
to skip it — required for Claude.ai subscriptions whose server-side
content filter rejects mcp_*-prefixed tool names not registered with the
account's Claude Code MCP setup. Symptom is a misleading HTTP 400
"out of extra usage" on every request that carries 1+ tools.
"""
import os as _os
val = (_os.environ.get("HERMES_OAUTH_NO_MCP_PREFIX") or "").strip().lower()
return val not in ("1", "true", "yes", "on")


def _get_claude_code_version() -> str:
"""Lazily detect the installed Claude Code version when OAuth headers need it."""
global _claude_code_version_cache
Expand Down Expand Up @@ -503,7 +517,15 @@ def _common_betas_for_base_url(
if _requires_bearer_auth(base_url):
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
return [b for b in _COMMON_BETAS if b not in _stripped]
if drop_context_1m_beta:
# HERMES_OAUTH_FORCE_DROP_1M_BETA=1 forces the strip on every
# build_anthropic_client call (including auxiliary clients like
# title_generator/summarization that don't thread drop_context_1m_beta=True
# explicitly). Without this, Claude.ai OAuth subscriptions hit
# "The long context beta is not yet available for this subscription"
# on any auxiliary call and the gateway/cron flow degrades.
import os as _os_for_drop
_force_drop = (_os_for_drop.environ.get("HERMES_OAUTH_FORCE_DROP_1M_BETA") or "").strip().lower() in ("1", "true", "yes", "on")
if drop_context_1m_beta or _force_drop:
return [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
return _COMMON_BETAS

Expand Down Expand Up @@ -1845,23 +1867,27 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic")
block["text"] = text

# 3. Prefix tool names with mcp_ (Claude Code convention)
if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool:
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]

# 4. Prefix tool names in message history (tool_use and tool_result blocks)
for msg in anthropic_messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block:
if not block["name"].startswith(_MCP_TOOL_PREFIX):
block["name"] = _MCP_TOOL_PREFIX + block["name"]
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name
# 3. Prefix tool names with mcp_ (Claude Code convention).
# Skipped when HERMES_OAUTH_NO_MCP_PREFIX=1 — Anthropic's content
# filter rejects mcp_* names not registered with the account's
# Claude Code MCP setup, surfacing as HTTP 400 "out of extra usage".
if _oauth_mcp_prefix_enabled():
if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool:
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]

# 4. Prefix tool names in message history (tool_use blocks).
for msg in anthropic_messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block:
if not block["name"].startswith(_MCP_TOOL_PREFIX):
block["name"] = _MCP_TOOL_PREFIX + block["name"]
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name

kwargs: Dict[str, Any] = {
"model": model,
Expand Down
11 changes: 9 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4944,13 +4944,20 @@ def _build_system_prompt(self, system_message: str = None) -> str:
# Pointer to the hermes-agent skill + docs for user questions about Hermes itself.
prompt_parts.append(HERMES_AGENT_HELP_GUIDANCE)

# Tool-aware behavioral guidance: only inject when the tools are loaded
# Tool-aware behavioral guidance: only inject when the tools are loaded.
# When HERMES_OAUTH_COMPACT_GUIDANCE=1 is set (typically alongside
# HERMES_OAUTH_NO_MCP_PREFIX=1 on Claude.ai OAuth deployments), drop
# SKILLS_GUIDANCE — combined with MEMORY_GUIDANCE it trips Anthropic's
# server-side content filter and the request fails with a misleading
# HTTP 400 "out of extra usage".
import os as _os_for_compact
_compact = (_os_for_compact.environ.get("HERMES_OAUTH_COMPACT_GUIDANCE") or "").strip().lower() in ("1", "true", "yes", "on")
tool_guidance = []
if "memory" in self.valid_tool_names:
tool_guidance.append(MEMORY_GUIDANCE)
if "session_search" in self.valid_tool_names:
tool_guidance.append(SESSION_SEARCH_GUIDANCE)
if "skill_manage" in self.valid_tool_names:
if "skill_manage" in self.valid_tool_names and not _compact:
tool_guidance.append(SKILLS_GUIDANCE)
# Kanban worker/orchestrator lifecycle — only present when the
# dispatcher spawned this process (kanban_show check_fn gates on
Expand Down
189 changes: 189 additions & 0 deletions scripts/hermes-agent-updater.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# hermes-agent-updater.sh — automatic update, dependency sync, service restart, Telegram notify
# Runs via cron every 2 hours. Checks for upstream updates, applies them, restarts services, sends summary.
#
# Deployment example — paths and REMOTE_NAME/REMOTE_BRANCH below are tuned for
# a single Hetzner host running the gateway as a systemd unit. Adjust before
# reusing on another box. Companion: scripts/hermes-agent-warmup.py.

set -uo pipefail

REPO_DIR="/home/leos/hermes-agent"
VENV="$REPO_DIR/venv/bin"
ENV_FILE="/home/leos/.hermes/.env"
LOG_FILE="/tmp/hermes-updater.log"
LOCK_FILE="/tmp/hermes-updater.lock"

# uv binary — venv is uv-managed (no pip/ensurepip inside on purpose).
# Use full path because cron's PATH does not include ~/.local/bin.
UV_BIN="/home/leos/.local/bin/uv"

# Which remote/branch to track. We follow the masserfx fork's OAuth
# content-filter workaround branch, not upstream NousResearch/main,
# because the running gateway depends on patches that only exist there
# (HERMES_OAUTH_NO_MCP_PREFIX, COMPACT_GUIDANCE, FORCE_DROP_1M_BETA gates).
REMOTE_NAME="fork"
REMOTE_BRANCH="fix/oauth-content-filter-workarounds"

log() { echo "$(date '+%Y-%m-%d %H:%M:%S'): $*" >> "$LOG_FILE"; }

# Load Telegram credentials from .env
TELEGRAM_BOT_TOKEN=""
TELEGRAM_CHAT_ID=""
while IFS='=' read -r key value; do
key="${key%%#*}" # strip comments
key="${key// /}" # strip spaces
value="${value## }" # trim leading space
value="${value%% }" # trim trailing space
case "$key" in
TELEGRAM_BOT_TOKEN) TELEGRAM_BOT_TOKEN="$value" ;;
TELEGRAM_ALLOWED_USERS) TELEGRAM_CHAT_ID="$value" ;;
esac
done < "$ENV_FILE"

if [[ -z "$TELEGRAM_BOT_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then
log "Missing TELEGRAM_BOT_TOKEN or TELEGRAM_ALLOWED_USERS in $ENV_FILE"
exit 1
fi

send_telegram() {
local message="$1"
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="$TELEGRAM_CHAT_ID" \
-d text="$message" \
-d parse_mode="Markdown" \
--max-time 30 > /dev/null 2>&1 || true
}

# Prevent concurrent runs
if [[ -f "$LOCK_FILE" ]]; then
pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
log "Already running (PID $pid), skipping"
exit 0
fi
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT

cd "$REPO_DIR"

# Fetch from tracked remote
git fetch "$REMOTE_NAME" --quiet 2>> "$LOG_FILE"

LOCAL_HEAD=$(git rev-parse HEAD)
REMOTE_HEAD=$(git rev-parse "$REMOTE_NAME/$REMOTE_BRANCH")

if [[ "$LOCAL_HEAD" == "$REMOTE_HEAD" ]]; then
log "Up to date ($(echo "$LOCAL_HEAD" | cut -c1-8))"
exit 0
fi

# Count commits behind
BEHIND=$(git rev-list "HEAD..$REMOTE_NAME/$REMOTE_BRANCH" --count)
COMMIT_LOG=$(git log --oneline "HEAD..$REMOTE_NAME/$REMOTE_BRANCH" 2>/dev/null | head -15 || true)
FIRST_COMMIT=$(echo "$COMMIT_LOG" | tail -1 | cut -c1-8)
LAST_COMMIT=$(echo "$COMMIT_LOG" | head -1 | cut -c1-8)

log "Update available — $BEHIND commits behind. Updating..."

# Stash any local changes
STASHED=false
if ! git diff --quiet HEAD 2>/dev/null || ! git diff --cached --quiet HEAD 2>/dev/null; then
git stash push -m "auto-updater $(date +%Y%m%d_%H%M%S)" --quiet 2>> "$LOG_FILE"
STASHED=true
fi

# Pull updates (fast-forward only for safety)
if ! git pull --ff-only "$REMOTE_NAME" "$REMOTE_BRANCH" >> "$LOG_FILE" 2>&1; then
log "Fast-forward failed, resetting to $REMOTE_NAME/$REMOTE_BRANCH"
git reset --hard "$REMOTE_NAME/$REMOTE_BRANCH" >> "$LOG_FILE" 2>&1
fi

NEW_HEAD=$(git rev-parse --short HEAD)

# Sync dependencies via uv (project is uv-managed, uv.lock is authoritative).
log "Syncing dependencies via uv..."
"$UV_BIN" sync --quiet >> "$LOG_FILE" 2>&1 || log "WARN: uv sync failed"

# Quick smoke test
log "Running smoke tests..."
TEST_OUTPUT=$("$VENV/python" -m pytest tests/test_imports.py -q --tb=short 2>&1 | tail -3 || true)

# Collect services to restart
SERVICES=(hermes-agent paperclip-memory-api paperclip-agent-daemon)
RESTART_RESULTS=""

for svc in "${SERVICES[@]}"; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
sudo systemctl restart "$svc" 2>> "$LOG_FILE"
sleep 3
if systemctl is-active --quiet "$svc" 2>/dev/null; then
RESTART_RESULTS="${RESTART_RESULTS} ✅ ${svc}
"
else
RESTART_RESULTS="${RESTART_RESULTS} ❌ ${svc} (failed)
"
fi
else
RESTART_RESULTS="${RESTART_RESULTS} ⏭ ${svc} (not running)
"
fi
done

# Pop stash if we stashed
if [[ "$STASHED" == "true" ]]; then
git stash pop --quiet 2>> "$LOG_FILE" || true
fi

# Warm-up: verify hermes-agent's OAuth path actually works after the restart.
# Without this, observed regression: auto-update + restart leaves the service
# in a state where the first real Telegram request fails with HTTP 400
# "out of extra usage" until a second restart clears it. The warm-up makes
# a tiny direct API call exercising the same env-flag patches the gateway
# uses; on failure we restart hermes-agent once more.
WARMUP_RESULT="skipped"
if [[ -x /home/leos/hermes-agent-warmup.py ]] && systemctl is-active --quiet hermes-agent 2>/dev/null; then
sleep 8 # give hermes-agent a moment to fully bind its sockets / load env
log "Running warm-up check..."
if "$VENV/python" /home/leos/hermes-agent-warmup.py >> "$LOG_FILE" 2>&1; then
WARMUP_RESULT="OK"
log "Warm-up OK"
else
log "Warm-up FAILED — restarting hermes-agent once more"
sudo systemctl restart hermes-agent 2>> "$LOG_FILE"
sleep 5
if "$VENV/python" /home/leos/hermes-agent-warmup.py >> "$LOG_FILE" 2>&1; then
WARMUP_RESULT="recovered"
log "Warm-up recovered after second restart"
else
WARMUP_RESULT="STILL FAILING"
log "Warm-up still failing after retry — manual intervention needed"
fi
fi
fi

# Build Telegram message
COMMIT_PREVIEW=$(echo "$COMMIT_LOG" | head -8)

MSG="🔄 *Hermes Agent Auto-Update*

📦 *${BEHIND} nových commitů* (${FIRST_COMMIT} → ${LAST_COMMIT})
🏷 HEAD: \`${NEW_HEAD}\`

*Poslední změny:*
\`\`\`
${COMMIT_PREVIEW}
\`\`\`

*Testy:* ${TEST_OUTPUT}

*Služby:*
${RESTART_RESULTS}
*Warm-up:* ${WARMUP_RESULT}
🕐 $(date '+%Y-%m-%d %H:%M:%S')"

send_telegram "$MSG"

log "Update complete — $BEHIND commits, services restarted, notification sent"
87 changes: 87 additions & 0 deletions scripts/hermes-agent-warmup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Warm-up health check for hermes-agent — validates that the running service
can actually make a successful Anthropic API call after restart.

Detects the regression we observed on 2026-05-06: auto-update + restart
sometimes left the service in a state where the first real request would
fail with HTTP 400 'out of extra usage'. We exercise the full OAuth path
(env vars + patched mcp_/beta gates) by making a tiny direct API call.

Deployment example — sys.path insert below assumes the repo lives at
/home/leos/hermes-agent. Companion: scripts/hermes-agent-updater.sh.

Exit codes:
0 — OK (request succeeded)
1 — failed; caller should restart hermes-agent again
"""
import os
import sys
import time

# Mirror what hermes_cli/main.py does at startup so the env vars from
# ~/.hermes/.env take effect for the OAuth gates.
sys.path.insert(0, "/home/leos/hermes-agent")
try:
from hermes_cli.env_loader import load_hermes_dotenv

load_hermes_dotenv()
except Exception as e:
print(f"WARN: load_hermes_dotenv failed: {e}", file=sys.stderr)

from agent.anthropic_adapter import build_anthropic_client

try:
from agent.anthropic_adapter import _oauth_mcp_prefix_enabled
except ImportError:
_oauth_mcp_prefix_enabled = None

from agent.credential_pool import load_pool


def main() -> int:
env_summary = {
k: os.environ.get(k, "<unset>")
for k in (
"HERMES_OAUTH_NO_MCP_PREFIX",
"HERMES_OAUTH_COMPACT_GUIDANCE",
"HERMES_OAUTH_FORCE_DROP_1M_BETA",
)
}
print(f"warmup env: {env_summary}")
prefix_state = _oauth_mcp_prefix_enabled() if _oauth_mcp_prefix_enabled else "<symbol unavailable>"
print(f"warmup mcp_prefix_enabled={prefix_state} (expect False)")

pool = load_pool("anthropic")
entries = pool.entries()
if not entries:
print("FAIL: no anthropic credentials in pool", file=sys.stderr)
return 1
entry = entries[0]
token = entry.access_token
if not token:
print(f"FAIL: pool entry {entry.source} has no access_token", file=sys.stderr)
return 1

client = build_anthropic_client(token, base_url="https://api.anthropic.com")

t0 = time.time()
try:
resp = client.messages.create(
model="claude-haiku-4-5",
max_tokens=20,
system="You are Claude Code, Anthropic's official CLI for Claude.",
messages=[{"role": "user", "content": "Reply with exactly: WARMUP-OK"}],
)
except Exception as e:
msg = str(e)[:200]
print(f"FAIL: messages.create raised {type(e).__name__}: {msg}", file=sys.stderr)
return 1
dt = time.time() - t0

text = resp.content[0].text if resp.content else ""
print(f"warmup OK in {dt:.2f}s, in={resp.usage.input_tokens} out={resp.usage.output_tokens}: {text!r}")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading