mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 07:51:45 +08:00
Compare commits
12 Commits
docs/hooks
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23e9af7337 | ||
|
|
347d40a33f | ||
|
|
a65bf2f99f | ||
|
|
71a2754d53 | ||
|
|
80cc27eb9d | ||
|
|
1b24a226ea | ||
|
|
9b32f846a8 | ||
|
|
7ca22ea11b | ||
|
|
ef47531617 | ||
|
|
b36fe9282a | ||
|
|
1e9ff53a74 | ||
|
|
e93b539a8f |
@@ -40,7 +40,7 @@ _MIN_SUMMARY_TOKENS = 2000
|
||||
# Proportion of compressed content to allocate for summary
|
||||
_SUMMARY_RATIO = 0.20
|
||||
# Absolute ceiling for summary tokens (even on very large context windows)
|
||||
_SUMMARY_TOKENS_CEILING = 32_000
|
||||
_SUMMARY_TOKENS_CEILING = 12_000
|
||||
|
||||
# Placeholder used when pruning old tool results
|
||||
_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
|
||||
@@ -63,10 +63,10 @@ class ContextCompressor:
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
threshold_percent: float = 0.80,
|
||||
threshold_percent: float = 0.50,
|
||||
protect_first_n: int = 3,
|
||||
protect_last_n: int = 20,
|
||||
summary_target_ratio: float = 0.40,
|
||||
summary_target_ratio: float = 0.20,
|
||||
quiet_mode: bool = False,
|
||||
summary_model_override: str = None,
|
||||
base_url: str = "",
|
||||
@@ -92,8 +92,8 @@ class ContextCompressor:
|
||||
self.threshold_tokens = int(self.context_length * threshold_percent)
|
||||
self.compression_count = 0
|
||||
|
||||
# Derive token budgets from the target ratio and context length
|
||||
target_tokens = int(self.context_length * self.summary_target_ratio)
|
||||
# Derive token budgets: ratio is relative to the threshold, not total context
|
||||
target_tokens = int(self.threshold_tokens * self.summary_target_ratio)
|
||||
self.tail_token_budget = target_tokens
|
||||
self.max_summary_tokens = min(
|
||||
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
|
||||
|
||||
@@ -236,23 +236,24 @@ browser:
|
||||
# 5. Summarizes middle turns using a fast/cheap model
|
||||
# 6. Inserts summary as a user message, continues conversation seamlessly
|
||||
#
|
||||
# Post-compression size scales with the model's context window via target_ratio:
|
||||
# MiniMax 200K context → ~80K post-compression (at 0.40 ratio)
|
||||
# GPT-5 1M context → ~400K post-compression (at 0.40 ratio)
|
||||
# Post-compression tail budget is target_ratio × threshold × context_length:
|
||||
# 200K context, threshold 0.50, ratio 0.20 → 20K tokens of recent tail preserved
|
||||
# 1M context, threshold 0.50, ratio 0.20 → 100K tokens of recent tail preserved
|
||||
#
|
||||
compression:
|
||||
# Enable automatic context compression (default: true)
|
||||
# Set to false if you prefer to manage context manually or want errors on overflow
|
||||
enabled: true
|
||||
|
||||
# Trigger compression at this % of model's context limit (default: 0.80 = 80%)
|
||||
# Trigger compression at this % of model's context limit (default: 0.50 = 50%)
|
||||
# Lower values = more aggressive compression, higher values = compress later
|
||||
threshold: 0.80
|
||||
threshold: 0.50
|
||||
|
||||
# Target post-compression size as a fraction of context window (default: 0.40 = 40%)
|
||||
# Controls how much context survives compression. Tail token budget and summary
|
||||
# cap scale with this value. Range: 0.10 - 0.80
|
||||
target_ratio: 0.40
|
||||
# Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%)
|
||||
# e.g. 20% of 50% threshold = 10% of total context kept as recent messages.
|
||||
# Summary output is separately capped at 12K tokens (Gemini output limit).
|
||||
# Range: 0.10 - 0.80
|
||||
target_ratio: 0.20
|
||||
|
||||
# Number of most-recent messages to always preserve (default: 20 ≈ 10 full turns)
|
||||
# Higher values keep more recent conversation intact at the cost of more aggressive
|
||||
|
||||
@@ -45,6 +45,7 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8642
|
||||
MAX_STORED_RESPONSES = 100
|
||||
MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
|
||||
|
||||
|
||||
def check_api_server_requirements() -> bool:
|
||||
@@ -194,6 +195,73 @@ else:
|
||||
cors_middleware = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _openai_error(message: str, err_type: str = "invalid_request_error", param: str = None, code: str = None) -> Dict[str, Any]:
|
||||
"""OpenAI-style error envelope."""
|
||||
return {
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": err_type,
|
||||
"param": param,
|
||||
"code": code,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if AIOHTTP_AVAILABLE:
|
||||
@web.middleware
|
||||
async def body_limit_middleware(request, handler):
|
||||
"""Reject overly large request bodies early based on Content-Length."""
|
||||
if request.method in ("POST", "PUT", "PATCH"):
|
||||
cl = request.headers.get("Content-Length")
|
||||
if cl is not None:
|
||||
try:
|
||||
if int(cl) > MAX_REQUEST_BYTES:
|
||||
return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413)
|
||||
except ValueError:
|
||||
return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400)
|
||||
return await handler(request)
|
||||
else:
|
||||
body_limit_middleware = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class _IdempotencyCache:
|
||||
"""In-memory idempotency cache with TTL and basic LRU semantics."""
|
||||
def __init__(self, max_items: int = 1000, ttl_seconds: int = 300):
|
||||
from collections import OrderedDict
|
||||
self._store = OrderedDict()
|
||||
self._ttl = ttl_seconds
|
||||
self._max = max_items
|
||||
|
||||
def _purge(self):
|
||||
import time as _t
|
||||
now = _t.time()
|
||||
expired = [k for k, v in self._store.items() if now - v["ts"] > self._ttl]
|
||||
for k in expired:
|
||||
self._store.pop(k, None)
|
||||
while len(self._store) > self._max:
|
||||
self._store.popitem(last=False)
|
||||
|
||||
async def get_or_set(self, key: str, fingerprint: str, compute_coro):
|
||||
self._purge()
|
||||
item = self._store.get(key)
|
||||
if item and item["fp"] == fingerprint:
|
||||
return item["resp"]
|
||||
resp = await compute_coro()
|
||||
import time as _t
|
||||
self._store[key] = {"resp": resp, "fp": fingerprint, "ts": _t.time()}
|
||||
self._purge()
|
||||
return resp
|
||||
|
||||
|
||||
_idem_cache = _IdempotencyCache()
|
||||
|
||||
|
||||
def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
|
||||
from hashlib import sha256
|
||||
subset = {k: body.get(k) for k in keys}
|
||||
return sha256(repr(subset).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class APIServerAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
OpenAI-compatible HTTP API server adapter.
|
||||
@@ -360,10 +428,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response(
|
||||
{"error": {"message": "Invalid JSON in request body", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(_openai_error("Invalid JSON in request body"), status=400)
|
||||
|
||||
messages = body.get("messages")
|
||||
if not messages or not isinstance(messages, list):
|
||||
@@ -428,20 +493,35 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
request, completion_id, model_name, created, _stream_q, agent_task
|
||||
)
|
||||
|
||||
# Non-streaming: run the agent and return full response
|
||||
try:
|
||||
result, usage = await self._run_agent(
|
||||
# Non-streaming: run the agent (with optional Idempotency-Key)
|
||||
async def _compute_completion():
|
||||
return await self._run_agent(
|
||||
user_message=user_message,
|
||||
conversation_history=history,
|
||||
ephemeral_system_prompt=system_prompt,
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
|
||||
return web.json_response(
|
||||
{"error": {"message": f"Internal server error: {e}", "type": "server_error"}},
|
||||
status=500,
|
||||
)
|
||||
|
||||
idempotency_key = request.headers.get("Idempotency-Key")
|
||||
if idempotency_key:
|
||||
fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"])
|
||||
try:
|
||||
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion)
|
||||
except Exception as e:
|
||||
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
|
||||
return web.json_response(
|
||||
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
||||
status=500,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
result, usage = await _compute_completion()
|
||||
except Exception as e:
|
||||
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
|
||||
return web.json_response(
|
||||
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
||||
status=500,
|
||||
)
|
||||
|
||||
final_response = result.get("final_response", "")
|
||||
if not final_response:
|
||||
@@ -567,10 +647,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
raw_input = body.get("input")
|
||||
if raw_input is None:
|
||||
return web.json_response(
|
||||
{"error": {"message": "Missing 'input' field", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(_openai_error("Missing 'input' field"), status=400)
|
||||
|
||||
instructions = body.get("instructions")
|
||||
previous_response_id = body.get("previous_response_id")
|
||||
@@ -579,10 +656,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
# conversation and previous_response_id are mutually exclusive
|
||||
if conversation and previous_response_id:
|
||||
return web.json_response(
|
||||
{"error": {"message": "Cannot use both 'conversation' and 'previous_response_id'", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(_openai_error("Cannot use both 'conversation' and 'previous_response_id'"), status=400)
|
||||
|
||||
# Resolve conversation name to latest response_id
|
||||
if conversation:
|
||||
@@ -613,20 +687,14 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
content = "\n".join(text_parts)
|
||||
input_messages.append({"role": role, "content": content})
|
||||
else:
|
||||
return web.json_response(
|
||||
{"error": {"message": "'input' must be a string or array", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
|
||||
|
||||
# Reconstruct conversation history from previous_response_id
|
||||
conversation_history: List[Dict[str, str]] = []
|
||||
if previous_response_id:
|
||||
stored = self._response_store.get(previous_response_id)
|
||||
if stored is None:
|
||||
return web.json_response(
|
||||
{"error": {"message": f"Previous response not found: {previous_response_id}", "type": "invalid_request_error"}},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
|
||||
conversation_history = list(stored.get("conversation_history", []))
|
||||
# If no instructions provided, carry forward from previous
|
||||
if instructions is None:
|
||||
@@ -639,30 +707,46 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
# Last input message is the user_message
|
||||
user_message = input_messages[-1].get("content", "") if input_messages else ""
|
||||
if not user_message:
|
||||
return web.json_response(
|
||||
{"error": {"message": "No user message found in input", "type": "invalid_request_error"}},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(_openai_error("No user message found in input"), status=400)
|
||||
|
||||
# Truncation support
|
||||
if body.get("truncation") == "auto" and len(conversation_history) > 100:
|
||||
conversation_history = conversation_history[-100:]
|
||||
|
||||
# Run the agent
|
||||
# Run the agent (with Idempotency-Key support)
|
||||
session_id = str(uuid.uuid4())
|
||||
try:
|
||||
result, usage = await self._run_agent(
|
||||
|
||||
async def _compute_response():
|
||||
return await self._run_agent(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=instructions,
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error running agent for responses: %s", e, exc_info=True)
|
||||
return web.json_response(
|
||||
{"error": {"message": f"Internal server error: {e}", "type": "server_error"}},
|
||||
status=500,
|
||||
|
||||
idempotency_key = request.headers.get("Idempotency-Key")
|
||||
if idempotency_key:
|
||||
fp = _make_request_fingerprint(
|
||||
body,
|
||||
keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"],
|
||||
)
|
||||
try:
|
||||
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response)
|
||||
except Exception as e:
|
||||
logger.error("Error running agent for responses: %s", e, exc_info=True)
|
||||
return web.json_response(
|
||||
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
||||
status=500,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
result, usage = await _compute_response()
|
||||
except Exception as e:
|
||||
logger.error("Error running agent for responses: %s", e, exc_info=True)
|
||||
return web.json_response(
|
||||
_openai_error(f"Internal server error: {e}", err_type="server_error"),
|
||||
status=500,
|
||||
)
|
||||
|
||||
final_response = result.get("final_response", "")
|
||||
if not final_response:
|
||||
@@ -726,10 +810,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
response_id = request.match_info["response_id"]
|
||||
stored = self._response_store.get(response_id)
|
||||
if stored is None:
|
||||
return web.json_response(
|
||||
{"error": {"message": f"Response not found: {response_id}", "type": "invalid_request_error"}},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
|
||||
|
||||
return web.json_response(stored["response"])
|
||||
|
||||
@@ -742,10 +823,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
response_id = request.match_info["response_id"]
|
||||
deleted = self._response_store.delete(response_id)
|
||||
if not deleted:
|
||||
return web.json_response(
|
||||
{"error": {"message": f"Response not found: {response_id}", "type": "invalid_request_error"}},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
|
||||
|
||||
return web.json_response({
|
||||
"id": response_id,
|
||||
@@ -1090,7 +1168,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
try:
|
||||
self._app = web.Application(middlewares=[cors_middleware])
|
||||
mws = [mw for mw in (cors_middleware, body_limit_middleware) if mw is not None]
|
||||
self._app = web.Application(middlewares=mws)
|
||||
self._app["api_server_adapter"] = self
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/models", self._handle_models)
|
||||
|
||||
@@ -163,8 +163,8 @@ DEFAULT_CONFIG = {
|
||||
|
||||
"compression": {
|
||||
"enabled": True,
|
||||
"threshold": 0.80, # compress when context usage exceeds this ratio
|
||||
"target_ratio": 0.40, # fraction of context to preserve as recent tail
|
||||
"threshold": 0.50, # compress when context usage exceeds this ratio
|
||||
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
|
||||
"protect_last_n": 20, # minimum recent messages to keep uncompressed
|
||||
"summary_model": "", # empty = use main configured model
|
||||
"summary_provider": "auto",
|
||||
@@ -1686,8 +1686,8 @@ def show_config():
|
||||
enabled = compression.get('enabled', True)
|
||||
print(f" Enabled: {'yes' if enabled else 'no'}")
|
||||
if enabled:
|
||||
print(f" Threshold: {compression.get('threshold', 0.80) * 100:.0f}%")
|
||||
print(f" Target ratio: {compression.get('target_ratio', 0.40) * 100:.0f}% of context preserved")
|
||||
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
|
||||
print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
|
||||
print(f" Protect last: {compression.get('protect_last_n', 20)} messages")
|
||||
_sm = compression.get('summary_model', '') or '(main model)'
|
||||
print(f" Model: {_sm}")
|
||||
|
||||
@@ -119,6 +119,70 @@ MIGRATION_OPTION_METADATA: Dict[str, Dict[str, str]] = {
|
||||
"label": "Archive unmapped docs",
|
||||
"description": "Archive compatible-but-unmapped docs for later manual review.",
|
||||
},
|
||||
"mcp-servers": {
|
||||
"label": "MCP servers",
|
||||
"description": "Import MCP server definitions from OpenClaw into Hermes config.yaml.",
|
||||
},
|
||||
"plugins-config": {
|
||||
"label": "Plugins configuration",
|
||||
"description": "Archive OpenClaw plugin configuration and installed extensions for manual review.",
|
||||
},
|
||||
"cron-jobs": {
|
||||
"label": "Cron / scheduled tasks",
|
||||
"description": "Import cron job definitions. Archive for manual recreation via 'hermes cron'.",
|
||||
},
|
||||
"hooks-config": {
|
||||
"label": "Hooks and webhooks",
|
||||
"description": "Archive OpenClaw hook configuration (internal hooks, webhooks, Gmail integration).",
|
||||
},
|
||||
"agent-config": {
|
||||
"label": "Agent defaults and multi-agent setup",
|
||||
"description": "Import agent defaults (compaction, context, thinking) into Hermes config. Archive multi-agent list.",
|
||||
},
|
||||
"gateway-config": {
|
||||
"label": "Gateway configuration",
|
||||
"description": "Import gateway port and auth settings. Archive full gateway config for manual setup.",
|
||||
},
|
||||
"session-config": {
|
||||
"label": "Session configuration",
|
||||
"description": "Import session reset policies (daily/idle) into Hermes session_reset config.",
|
||||
},
|
||||
"full-providers": {
|
||||
"label": "Full model provider definitions",
|
||||
"description": "Import custom model providers (baseUrl, apiType, headers) into Hermes custom_providers.",
|
||||
},
|
||||
"deep-channels": {
|
||||
"label": "Deep channel configuration",
|
||||
"description": "Import extended channel settings (Matrix, Mattermost, IRC, group configs). Archive complex settings.",
|
||||
},
|
||||
"browser-config": {
|
||||
"label": "Browser configuration",
|
||||
"description": "Import browser automation settings into Hermes config.yaml.",
|
||||
},
|
||||
"tools-config": {
|
||||
"label": "Tools configuration",
|
||||
"description": "Import tool settings (exec timeout, sandbox, web search) into Hermes config.yaml.",
|
||||
},
|
||||
"approvals-config": {
|
||||
"label": "Approval rules",
|
||||
"description": "Import approval mode and rules into Hermes config.yaml approvals section.",
|
||||
},
|
||||
"memory-backend": {
|
||||
"label": "Memory backend configuration",
|
||||
"description": "Archive OpenClaw memory backend settings (QMD, vector search, citations) for manual review.",
|
||||
},
|
||||
"skills-config": {
|
||||
"label": "Skills registry configuration",
|
||||
"description": "Archive per-skill enabled/config/env settings from OpenClaw skills.entries.",
|
||||
},
|
||||
"ui-identity": {
|
||||
"label": "UI and identity settings",
|
||||
"description": "Archive OpenClaw UI theme, assistant identity, and display preferences.",
|
||||
},
|
||||
"logging-config": {
|
||||
"label": "Logging and diagnostics",
|
||||
"description": "Archive OpenClaw logging and diagnostics configuration.",
|
||||
},
|
||||
}
|
||||
MIGRATION_PRESETS: Dict[str, set[str]] = {
|
||||
"user-data": {
|
||||
@@ -139,6 +203,22 @@ MIGRATION_PRESETS: Dict[str, set[str]] = {
|
||||
"shared-skills",
|
||||
"daily-memory",
|
||||
"archive",
|
||||
"mcp-servers",
|
||||
"agent-config",
|
||||
"session-config",
|
||||
"browser-config",
|
||||
"tools-config",
|
||||
"approvals-config",
|
||||
"deep-channels",
|
||||
"full-providers",
|
||||
"plugins-config",
|
||||
"cron-jobs",
|
||||
"hooks-config",
|
||||
"memory-backend",
|
||||
"skills-config",
|
||||
"ui-identity",
|
||||
"logging-config",
|
||||
"gateway-config",
|
||||
},
|
||||
"full": set(MIGRATION_OPTION_METADATA),
|
||||
}
|
||||
@@ -578,6 +658,28 @@ class Migrator:
|
||||
),
|
||||
)
|
||||
self.run_if_selected("archive", self.archive_docs)
|
||||
|
||||
# ── v2 migration modules ──────────────────────────────
|
||||
self.run_if_selected("mcp-servers", lambda: self.migrate_mcp_servers(config))
|
||||
self.run_if_selected("plugins-config", lambda: self.migrate_plugins_config(config))
|
||||
self.run_if_selected("cron-jobs", lambda: self.migrate_cron_jobs(config))
|
||||
self.run_if_selected("hooks-config", lambda: self.migrate_hooks_config(config))
|
||||
self.run_if_selected("agent-config", lambda: self.migrate_agent_config(config))
|
||||
self.run_if_selected("gateway-config", lambda: self.migrate_gateway_config(config))
|
||||
self.run_if_selected("session-config", lambda: self.migrate_session_config(config))
|
||||
self.run_if_selected("full-providers", lambda: self.migrate_full_providers(config))
|
||||
self.run_if_selected("deep-channels", lambda: self.migrate_deep_channels(config))
|
||||
self.run_if_selected("browser-config", lambda: self.migrate_browser_config(config))
|
||||
self.run_if_selected("tools-config", lambda: self.migrate_tools_config(config))
|
||||
self.run_if_selected("approvals-config", lambda: self.migrate_approvals_config(config))
|
||||
self.run_if_selected("memory-backend", lambda: self.migrate_memory_backend(config))
|
||||
self.run_if_selected("skills-config", lambda: self.migrate_skills_config(config))
|
||||
self.run_if_selected("ui-identity", lambda: self.migrate_ui_identity(config))
|
||||
self.run_if_selected("logging-config", lambda: self.migrate_logging_config(config))
|
||||
|
||||
# Generate migration notes
|
||||
self.generate_migration_notes()
|
||||
|
||||
return self.build_report()
|
||||
|
||||
def run_if_selected(self, option_id: str, func) -> None:
|
||||
@@ -1459,6 +1561,776 @@ class Migrator:
|
||||
else:
|
||||
self.record("archive", source, destination, "archived", reason)
|
||||
|
||||
# ── MCP servers ─────────────────────────────────────────────
|
||||
def migrate_mcp_servers(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
mcp_raw = (config.get("mcp") or {}).get("servers") or {}
|
||||
if not mcp_raw:
|
||||
self.record("mcp-servers", None, None, "skipped", "No MCP servers found in OpenClaw config")
|
||||
return
|
||||
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
existing_mcp = hermes_cfg.get("mcp_servers") or {}
|
||||
added = 0
|
||||
|
||||
for name, srv in mcp_raw.items():
|
||||
if not isinstance(srv, dict):
|
||||
continue
|
||||
if name in existing_mcp and not self.overwrite:
|
||||
self.record("mcp-servers", f"mcp.servers.{name}", f"mcp_servers.{name}", "conflict",
|
||||
"MCP server already exists in Hermes config")
|
||||
continue
|
||||
|
||||
hermes_srv: Dict[str, Any] = {}
|
||||
# STDIO transport
|
||||
if srv.get("command"):
|
||||
hermes_srv["command"] = srv["command"]
|
||||
if srv.get("args"):
|
||||
hermes_srv["args"] = srv["args"]
|
||||
if srv.get("env"):
|
||||
hermes_srv["env"] = srv["env"]
|
||||
if srv.get("cwd"):
|
||||
hermes_srv["cwd"] = srv["cwd"]
|
||||
# HTTP/SSE transport
|
||||
if srv.get("url"):
|
||||
hermes_srv["url"] = srv["url"]
|
||||
if srv.get("headers"):
|
||||
hermes_srv["headers"] = srv["headers"]
|
||||
if srv.get("auth"):
|
||||
hermes_srv["auth"] = srv["auth"]
|
||||
# Common fields
|
||||
if srv.get("enabled") is False:
|
||||
hermes_srv["enabled"] = False
|
||||
if srv.get("timeout"):
|
||||
hermes_srv["timeout"] = srv["timeout"]
|
||||
if srv.get("connectTimeout"):
|
||||
hermes_srv["connect_timeout"] = srv["connectTimeout"]
|
||||
# Tool filtering
|
||||
tools_cfg = srv.get("tools") or {}
|
||||
if tools_cfg.get("include") or tools_cfg.get("exclude"):
|
||||
hermes_srv["tools"] = {}
|
||||
if tools_cfg.get("include"):
|
||||
hermes_srv["tools"]["include"] = tools_cfg["include"]
|
||||
if tools_cfg.get("exclude"):
|
||||
hermes_srv["tools"]["exclude"] = tools_cfg["exclude"]
|
||||
# Sampling
|
||||
sampling = srv.get("sampling")
|
||||
if sampling and isinstance(sampling, dict):
|
||||
hermes_srv["sampling"] = {
|
||||
k: v for k, v in {
|
||||
"enabled": sampling.get("enabled"),
|
||||
"model": sampling.get("model"),
|
||||
"max_tokens_cap": sampling.get("maxTokensCap") or sampling.get("max_tokens_cap"),
|
||||
"timeout": sampling.get("timeout"),
|
||||
"max_rpm": sampling.get("maxRpm") or sampling.get("max_rpm"),
|
||||
}.items() if v is not None
|
||||
}
|
||||
|
||||
existing_mcp[name] = hermes_srv
|
||||
added += 1
|
||||
self.record("mcp-servers", f"mcp.servers.{name}", f"config.yaml mcp_servers.{name}",
|
||||
"migrated", servers_added=added)
|
||||
|
||||
if added > 0 and self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
hermes_cfg["mcp_servers"] = existing_mcp
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
|
||||
# ── Plugins ───────────────────────────────────────────────
|
||||
def migrate_plugins_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
plugins = config.get("plugins") or {}
|
||||
if not plugins:
|
||||
self.record("plugins-config", None, None, "skipped", "No plugins configuration found")
|
||||
return
|
||||
|
||||
# Archive the full plugins config
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "plugins-config.json"
|
||||
dest.write_text(json.dumps(plugins, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("plugins-config", "openclaw.json plugins.*", str(dest), "archived",
|
||||
"Plugins config archived for manual review")
|
||||
else:
|
||||
self.record("plugins-config", "openclaw.json plugins.*", "archive/plugins-config.json",
|
||||
"archived" if not self.execute else "migrated", "Would archive plugins config")
|
||||
|
||||
# Copy extensions directory if it exists
|
||||
ext_dir = self.source_root / "extensions"
|
||||
if ext_dir.is_dir() and self.archive_dir:
|
||||
dest_ext = self.archive_dir / "extensions"
|
||||
if self.execute:
|
||||
shutil.copytree(ext_dir, dest_ext, dirs_exist_ok=True)
|
||||
self.record("plugins-config", str(ext_dir), str(dest_ext), "archived",
|
||||
"Extensions directory archived")
|
||||
|
||||
# Extract any plugin env vars
|
||||
entries = plugins.get("entries") or {}
|
||||
for plugin_name, plugin_cfg in entries.items():
|
||||
if isinstance(plugin_cfg, dict):
|
||||
env_vars = plugin_cfg.get("env") or {}
|
||||
api_key = plugin_cfg.get("apiKey")
|
||||
if api_key and self.migrate_secrets:
|
||||
env_key = f"PLUGIN_{plugin_name.upper().replace('-', '_')}_API_KEY"
|
||||
self._set_env_var(env_key, api_key, f"plugins.entries.{plugin_name}.apiKey")
|
||||
|
||||
# ── Cron jobs ─────────────────────────────────────────────
|
||||
def migrate_cron_jobs(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
cron = config.get("cron") or {}
|
||||
if not cron:
|
||||
self.record("cron-jobs", None, None, "skipped", "No cron configuration found")
|
||||
return
|
||||
|
||||
# Archive the full cron config
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "cron-config.json"
|
||||
dest.write_text(json.dumps(cron, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("cron-jobs", "openclaw.json cron.*", str(dest), "archived",
|
||||
"Cron config archived. Use 'hermes cron' to recreate jobs manually.")
|
||||
else:
|
||||
self.record("cron-jobs", "openclaw.json cron.*", "archive/cron-config.json",
|
||||
"archived", "Would archive cron config")
|
||||
|
||||
# Also check for cron store files
|
||||
cron_store = self.source_root / "cron"
|
||||
if cron_store.is_dir() and self.archive_dir:
|
||||
dest_cron = self.archive_dir / "cron-store"
|
||||
if self.execute:
|
||||
shutil.copytree(cron_store, dest_cron, dirs_exist_ok=True)
|
||||
self.record("cron-jobs", str(cron_store), str(dest_cron), "archived",
|
||||
"Cron job store archived")
|
||||
|
||||
# ── Hooks ─────────────────────────────────────────────────
|
||||
def migrate_hooks_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
hooks = config.get("hooks") or {}
|
||||
if not hooks:
|
||||
self.record("hooks-config", None, None, "skipped", "No hooks configuration found")
|
||||
return
|
||||
|
||||
# Archive the full hooks config
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "hooks-config.json"
|
||||
dest.write_text(json.dumps(hooks, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("hooks-config", "openclaw.json hooks.*", str(dest), "archived",
|
||||
"Hooks config archived for manual review")
|
||||
else:
|
||||
self.record("hooks-config", "openclaw.json hooks.*", "archive/hooks-config.json",
|
||||
"archived", "Would archive hooks config")
|
||||
|
||||
# Copy workspace hooks directory
|
||||
for ws_name in ("workspace", "workspace.default"):
|
||||
hooks_dir = self.source_root / ws_name / "hooks"
|
||||
if hooks_dir.is_dir() and self.archive_dir:
|
||||
dest_hooks = self.archive_dir / "workspace-hooks"
|
||||
if self.execute:
|
||||
shutil.copytree(hooks_dir, dest_hooks, dirs_exist_ok=True)
|
||||
self.record("hooks-config", str(hooks_dir), str(dest_hooks), "archived",
|
||||
"Workspace hooks directory archived")
|
||||
break
|
||||
|
||||
# ── Agent config ──────────────────────────────────────────
|
||||
def migrate_agent_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
agents = config.get("agents") or {}
|
||||
defaults = agents.get("defaults") or {}
|
||||
agent_list = agents.get("list") or []
|
||||
|
||||
if not defaults and not agent_list:
|
||||
self.record("agent-config", None, None, "skipped", "No agent configuration found")
|
||||
return
|
||||
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
changes = False
|
||||
|
||||
# Map agent defaults
|
||||
agent_cfg = hermes_cfg.get("agent") or {}
|
||||
if defaults.get("contextTokens"):
|
||||
# No direct mapping but useful context
|
||||
pass
|
||||
if defaults.get("timeoutSeconds"):
|
||||
agent_cfg["max_turns"] = min(defaults["timeoutSeconds"] // 10, 200)
|
||||
changes = True
|
||||
if defaults.get("verboseDefault"):
|
||||
agent_cfg["verbose"] = defaults["verboseDefault"]
|
||||
changes = True
|
||||
if defaults.get("thinkingDefault"):
|
||||
# Map OpenClaw thinking -> Hermes reasoning_effort
|
||||
thinking = defaults["thinkingDefault"]
|
||||
if thinking in ("always", "high"):
|
||||
agent_cfg["reasoning_effort"] = "high"
|
||||
elif thinking in ("auto", "medium"):
|
||||
agent_cfg["reasoning_effort"] = "medium"
|
||||
elif thinking in ("off", "low", "none"):
|
||||
agent_cfg["reasoning_effort"] = "low"
|
||||
changes = True
|
||||
|
||||
# Map compaction -> compression
|
||||
compaction = defaults.get("compaction") or {}
|
||||
if compaction:
|
||||
compression = hermes_cfg.get("compression") or {}
|
||||
if compaction.get("mode") == "off":
|
||||
compression["enabled"] = False
|
||||
else:
|
||||
compression["enabled"] = True
|
||||
if compaction.get("timeout"):
|
||||
pass # No direct mapping
|
||||
if compaction.get("model"):
|
||||
compression["summary_model"] = compaction["model"]
|
||||
hermes_cfg["compression"] = compression
|
||||
changes = True
|
||||
|
||||
# Map humanDelay
|
||||
human_delay = defaults.get("humanDelay") or {}
|
||||
if human_delay:
|
||||
hd = hermes_cfg.get("human_delay") or {}
|
||||
if human_delay.get("enabled"):
|
||||
hd["mode"] = "natural"
|
||||
if human_delay.get("minMs"):
|
||||
hd["min_ms"] = human_delay["minMs"]
|
||||
if human_delay.get("maxMs"):
|
||||
hd["max_ms"] = human_delay["maxMs"]
|
||||
hermes_cfg["human_delay"] = hd
|
||||
changes = True
|
||||
|
||||
# Map userTimezone
|
||||
if defaults.get("userTimezone"):
|
||||
hermes_cfg["timezone"] = defaults["userTimezone"]
|
||||
changes = True
|
||||
|
||||
# Map terminal/exec settings
|
||||
exec_cfg = defaults.get("exec") or (config.get("tools") or {}).get("exec") or {}
|
||||
if exec_cfg:
|
||||
terminal_cfg = hermes_cfg.get("terminal") or {}
|
||||
if exec_cfg.get("timeout"):
|
||||
terminal_cfg["timeout"] = exec_cfg["timeout"]
|
||||
changes = True
|
||||
hermes_cfg["terminal"] = terminal_cfg
|
||||
|
||||
# Map sandbox -> terminal docker settings
|
||||
sandbox = defaults.get("sandbox") or {}
|
||||
if sandbox and sandbox.get("backend") == "docker":
|
||||
terminal_cfg = hermes_cfg.get("terminal") or {}
|
||||
terminal_cfg["backend"] = "docker"
|
||||
if sandbox.get("docker", {}).get("image"):
|
||||
terminal_cfg["docker_image"] = sandbox["docker"]["image"]
|
||||
hermes_cfg["terminal"] = terminal_cfg
|
||||
changes = True
|
||||
|
||||
if changes:
|
||||
hermes_cfg["agent"] = agent_cfg
|
||||
if self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
self.record("agent-config", "openclaw.json agents.defaults", "config.yaml agent/compression/terminal",
|
||||
"migrated", "Agent defaults mapped to Hermes config")
|
||||
|
||||
# Archive multi-agent list
|
||||
if agent_list:
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "agents-list.json"
|
||||
dest.write_text(json.dumps(agent_list, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("agent-config", "openclaw.json agents.list", "archive/agents-list.json",
|
||||
"archived", f"Multi-agent setup ({len(agent_list)} agents) archived for manual recreation")
|
||||
|
||||
# Archive bindings
|
||||
bindings = config.get("bindings") or []
|
||||
if bindings:
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "bindings.json"
|
||||
dest.write_text(json.dumps(bindings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("agent-config", "openclaw.json bindings", "archive/bindings.json",
|
||||
"archived", f"Agent routing bindings ({len(bindings)} rules) archived")
|
||||
|
||||
# ── Gateway config ────────────────────────────────────────
|
||||
def migrate_gateway_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
gateway = config.get("gateway") or {}
|
||||
if not gateway:
|
||||
self.record("gateway-config", None, None, "skipped", "No gateway configuration found")
|
||||
return
|
||||
|
||||
# Archive the full gateway config (complex, many settings)
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "gateway-config.json"
|
||||
dest.write_text(json.dumps(gateway, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("gateway-config", "openclaw.json gateway.*", "archive/gateway-config.json",
|
||||
"archived", "Gateway config archived. Use 'hermes gateway' to configure.")
|
||||
|
||||
# Extract gateway auth token to .env if present
|
||||
auth = gateway.get("auth") or {}
|
||||
if auth.get("token") and self.migrate_secrets:
|
||||
self._set_env_var("HERMES_GATEWAY_TOKEN", auth["token"], "gateway.auth.token")
|
||||
|
||||
# ── Session config ────────────────────────────────────────
|
||||
def migrate_session_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
session = config.get("session") or {}
|
||||
if not session:
|
||||
self.record("session-config", None, None, "skipped", "No session configuration found")
|
||||
return
|
||||
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
sr = hermes_cfg.get("session_reset") or {}
|
||||
changes = False
|
||||
|
||||
reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or {}
|
||||
if reset_triggers:
|
||||
daily = reset_triggers.get("daily") or {}
|
||||
idle = reset_triggers.get("idle") or {}
|
||||
|
||||
if daily.get("enabled") and idle.get("enabled"):
|
||||
sr["mode"] = "both"
|
||||
elif daily.get("enabled"):
|
||||
sr["mode"] = "daily"
|
||||
elif idle.get("enabled"):
|
||||
sr["mode"] = "idle"
|
||||
else:
|
||||
sr["mode"] = "none"
|
||||
|
||||
if daily.get("hour") is not None:
|
||||
sr["at_hour"] = daily["hour"]
|
||||
if idle.get("minutes") or idle.get("timeoutMinutes"):
|
||||
sr["idle_minutes"] = idle.get("minutes") or idle.get("timeoutMinutes")
|
||||
changes = True
|
||||
|
||||
if changes:
|
||||
hermes_cfg["session_reset"] = sr
|
||||
if self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
self.record("session-config", "openclaw.json session.resetTriggers",
|
||||
"config.yaml session_reset", "migrated")
|
||||
|
||||
# Archive full session config (identity links, thread bindings, etc.)
|
||||
complex_keys = {"identityLinks", "threadBindings", "maintenance", "scope", "sendPolicy"}
|
||||
complex_session = {k: v for k, v in session.items() if k in complex_keys and v}
|
||||
if complex_session and self.archive_dir:
|
||||
if self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "session-config.json"
|
||||
dest.write_text(json.dumps(complex_session, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("session-config", "openclaw.json session (advanced)",
|
||||
"archive/session-config.json", "archived",
|
||||
"Advanced session settings archived (identity links, thread bindings, etc.)")
|
||||
|
||||
# ── Full model providers ──────────────────────────────────
|
||||
def migrate_full_providers(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
models = config.get("models") or {}
|
||||
providers = models.get("providers") or {}
|
||||
if not providers:
|
||||
self.record("full-providers", None, None, "skipped", "No model providers found")
|
||||
return
|
||||
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
custom_providers = hermes_cfg.get("custom_providers") or []
|
||||
added = 0
|
||||
|
||||
# Well-known providers: just extract API keys
|
||||
WELL_KNOWN = {"openrouter", "openai", "anthropic", "deepseek", "google", "groq"}
|
||||
|
||||
for prov_name, prov_cfg in providers.items():
|
||||
if not isinstance(prov_cfg, dict):
|
||||
continue
|
||||
|
||||
# Extract API key to .env
|
||||
api_key = prov_cfg.get("apiKey") or prov_cfg.get("api_key")
|
||||
if api_key and self.migrate_secrets:
|
||||
env_key = f"{prov_name.upper().replace('-', '_')}_API_KEY"
|
||||
self._set_env_var(env_key, api_key, f"models.providers.{prov_name}.apiKey")
|
||||
|
||||
# For non-well-known providers, create custom_providers entry
|
||||
if prov_name.lower() not in WELL_KNOWN and prov_cfg.get("baseUrl"):
|
||||
# Check if already exists
|
||||
existing_names = {p.get("name", "").lower() for p in custom_providers}
|
||||
if prov_name.lower() in existing_names and not self.overwrite:
|
||||
self.record("full-providers", f"models.providers.{prov_name}",
|
||||
"config.yaml custom_providers", "conflict",
|
||||
f"Provider '{prov_name}' already exists")
|
||||
continue
|
||||
|
||||
api_type = prov_cfg.get("apiType") or prov_cfg.get("type") or "openai"
|
||||
api_mode_map = {
|
||||
"openai": "chat_completions",
|
||||
"anthropic": "anthropic_messages",
|
||||
"cohere": "chat_completions",
|
||||
}
|
||||
entry = {
|
||||
"name": prov_name,
|
||||
"base_url": prov_cfg["baseUrl"],
|
||||
"api_key": "", # referenced from .env
|
||||
"api_mode": api_mode_map.get(api_type, "chat_completions"),
|
||||
}
|
||||
custom_providers.append(entry)
|
||||
added += 1
|
||||
self.record("full-providers", f"models.providers.{prov_name}",
|
||||
f"config.yaml custom_providers[{prov_name}]", "migrated")
|
||||
|
||||
if added > 0 and self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
hermes_cfg["custom_providers"] = custom_providers
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
|
||||
# Archive model aliases/catalog
|
||||
agent_defaults = (config.get("agents") or {}).get("defaults") or {}
|
||||
model_aliases = agent_defaults.get("models") or {}
|
||||
if model_aliases:
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "model-aliases.json"
|
||||
dest.write_text(json.dumps(model_aliases, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("full-providers", "agents.defaults.models", "archive/model-aliases.json",
|
||||
"archived", f"Model aliases/catalog ({len(model_aliases)} entries) archived")
|
||||
|
||||
# ── Deep channel config ───────────────────────────────────
|
||||
def migrate_deep_channels(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
channels = config.get("channels") or {}
|
||||
if not channels:
|
||||
self.record("deep-channels", None, None, "skipped", "No channel configuration found")
|
||||
return
|
||||
|
||||
# Extended channel token/allowlist mapping
|
||||
CHANNEL_ENV_MAP = {
|
||||
"matrix": {"token": "MATRIX_ACCESS_TOKEN", "allowFrom": "MATRIX_ALLOWED_USERS",
|
||||
"extras": {"homeserverUrl": "MATRIX_HOMESERVER_URL", "userId": "MATRIX_USER_ID"}},
|
||||
"mattermost": {"token": "MATTERMOST_BOT_TOKEN", "allowFrom": "MATTERMOST_ALLOWED_USERS",
|
||||
"extras": {"url": "MATTERMOST_URL", "teamId": "MATTERMOST_TEAM_ID"}},
|
||||
"irc": {"extras": {"server": "IRC_SERVER", "nick": "IRC_NICK", "channels": "IRC_CHANNELS"}},
|
||||
"googlechat": {"extras": {"serviceAccountKeyPath": "GOOGLE_CHAT_SA_KEY_PATH"}},
|
||||
"imessage": {},
|
||||
"bluebubbles": {"extras": {"server": "BLUEBUBBLES_SERVER", "password": "BLUEBUBBLES_PASSWORD"}},
|
||||
"msteams": {"token": "MSTEAMS_BOT_TOKEN", "allowFrom": "MSTEAMS_ALLOWED_USERS"},
|
||||
"nostr": {"extras": {"nsec": "NOSTR_NSEC", "relays": "NOSTR_RELAYS"}},
|
||||
"twitch": {"token": "TWITCH_BOT_TOKEN", "extras": {"channels": "TWITCH_CHANNELS"}},
|
||||
}
|
||||
|
||||
for ch_name, ch_mapping in CHANNEL_ENV_MAP.items():
|
||||
ch_cfg = channels.get(ch_name) or {}
|
||||
if not ch_cfg:
|
||||
continue
|
||||
|
||||
# Extract tokens
|
||||
if ch_mapping.get("token") and ch_cfg.get("botToken") and self.migrate_secrets:
|
||||
self._set_env_var(ch_mapping["token"], ch_cfg["botToken"],
|
||||
f"channels.{ch_name}.botToken")
|
||||
if ch_mapping.get("allowFrom") and ch_cfg.get("allowFrom"):
|
||||
allow_val = ch_cfg["allowFrom"]
|
||||
if isinstance(allow_val, list):
|
||||
allow_val = ",".join(str(x) for x in allow_val)
|
||||
self._set_env_var(ch_mapping["allowFrom"], str(allow_val),
|
||||
f"channels.{ch_name}.allowFrom")
|
||||
# Extra fields
|
||||
for oc_key, env_key in (ch_mapping.get("extras") or {}).items():
|
||||
val = ch_cfg.get(oc_key)
|
||||
if val:
|
||||
if isinstance(val, list):
|
||||
val = ",".join(str(x) for x in val)
|
||||
is_secret = "password" in oc_key.lower() or "token" in oc_key.lower() or "nsec" in oc_key.lower()
|
||||
if is_secret and not self.migrate_secrets:
|
||||
continue
|
||||
self._set_env_var(env_key, str(val), f"channels.{ch_name}.{oc_key}")
|
||||
|
||||
# Map Discord-specific settings to Hermes config
|
||||
discord_cfg = channels.get("discord") or {}
|
||||
if discord_cfg:
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
discord_hermes = hermes_cfg.get("discord") or {}
|
||||
changed = False
|
||||
if "requireMention" in discord_cfg:
|
||||
discord_hermes["require_mention"] = discord_cfg["requireMention"]
|
||||
changed = True
|
||||
if discord_cfg.get("autoThread") is not None:
|
||||
discord_hermes["auto_thread"] = discord_cfg["autoThread"]
|
||||
changed = True
|
||||
if changed and self.execute:
|
||||
hermes_cfg["discord"] = discord_hermes
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
|
||||
# Archive complex channel configs (group settings, thread bindings, etc.)
|
||||
complex_archive = {}
|
||||
for ch_name, ch_cfg in channels.items():
|
||||
if not isinstance(ch_cfg, dict):
|
||||
continue
|
||||
complex_keys = {k: v for k, v in ch_cfg.items()
|
||||
if k not in ("botToken", "appToken", "allowFrom", "enabled")
|
||||
and v and k not in ("requireMention", "autoThread")}
|
||||
if complex_keys:
|
||||
complex_archive[ch_name] = complex_keys
|
||||
|
||||
if complex_archive and self.archive_dir:
|
||||
if self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "channels-deep-config.json"
|
||||
dest.write_text(json.dumps(complex_archive, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("deep-channels", "openclaw.json channels (advanced settings)",
|
||||
"archive/channels-deep-config.json", "archived",
|
||||
f"Deep channel config for {len(complex_archive)} channels archived")
|
||||
|
||||
# ── Browser config ────────────────────────────────────────
|
||||
def migrate_browser_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
browser = config.get("browser") or {}
|
||||
if not browser:
|
||||
self.record("browser-config", None, None, "skipped", "No browser configuration found")
|
||||
return
|
||||
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
browser_hermes = hermes_cfg.get("browser") or {}
|
||||
changed = False
|
||||
|
||||
if browser.get("inactivityTimeoutMs"):
|
||||
browser_hermes["inactivity_timeout"] = browser["inactivityTimeoutMs"] // 1000
|
||||
changed = True
|
||||
if browser.get("commandTimeoutMs"):
|
||||
browser_hermes["command_timeout"] = browser["commandTimeoutMs"] // 1000
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
hermes_cfg["browser"] = browser_hermes
|
||||
if self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
self.record("browser-config", "openclaw.json browser.*", "config.yaml browser",
|
||||
"migrated")
|
||||
|
||||
# Archive advanced browser settings
|
||||
advanced = {k: v for k, v in browser.items()
|
||||
if k not in ("inactivityTimeoutMs", "commandTimeoutMs") and v}
|
||||
if advanced and self.archive_dir:
|
||||
if self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "browser-config.json"
|
||||
dest.write_text(json.dumps(advanced, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("browser-config", "openclaw.json browser (advanced)",
|
||||
"archive/browser-config.json", "archived")
|
||||
|
||||
# ── Tools config ──────────────────────────────────────────
|
||||
def migrate_tools_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
tools = config.get("tools") or {}
|
||||
if not tools:
|
||||
self.record("tools-config", None, None, "skipped", "No tools configuration found")
|
||||
return
|
||||
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
changed = False
|
||||
|
||||
# Map exec timeout -> terminal timeout
|
||||
exec_cfg = tools.get("exec") or {}
|
||||
if exec_cfg.get("timeout"):
|
||||
terminal_cfg = hermes_cfg.get("terminal") or {}
|
||||
terminal_cfg["timeout"] = exec_cfg["timeout"]
|
||||
hermes_cfg["terminal"] = terminal_cfg
|
||||
changed = True
|
||||
|
||||
# Map web search API key
|
||||
web_cfg = tools.get("webSearch") or tools.get("web") or {}
|
||||
if web_cfg.get("braveApiKey") and self.migrate_secrets:
|
||||
self._set_env_var("BRAVE_API_KEY", web_cfg["braveApiKey"], "tools.webSearch.braveApiKey")
|
||||
|
||||
if changed and self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
self.record("tools-config", "openclaw.json tools.*", "config.yaml terminal",
|
||||
"migrated")
|
||||
|
||||
# Archive full tools config
|
||||
if self.archive_dir:
|
||||
if self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "tools-config.json"
|
||||
dest.write_text(json.dumps(tools, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("tools-config", "openclaw.json tools (full)", "archive/tools-config.json",
|
||||
"archived", "Full tools config archived for reference")
|
||||
|
||||
# ── Approvals config ──────────────────────────────────────
|
||||
def migrate_approvals_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
approvals = config.get("approvals") or {}
|
||||
if not approvals:
|
||||
self.record("approvals-config", None, None, "skipped", "No approvals configuration found")
|
||||
return
|
||||
|
||||
hermes_cfg_path = self.target_root / "config.yaml"
|
||||
hermes_cfg = load_yaml_file(hermes_cfg_path)
|
||||
|
||||
# Map approval mode
|
||||
mode = approvals.get("mode") or approvals.get("defaultMode")
|
||||
if mode:
|
||||
mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"}
|
||||
hermes_mode = mode_map.get(mode, "manual")
|
||||
hermes_cfg.setdefault("approvals", {})["mode"] = hermes_mode
|
||||
if self.execute:
|
||||
self.maybe_backup(hermes_cfg_path)
|
||||
dump_yaml_file(hermes_cfg_path, hermes_cfg)
|
||||
self.record("approvals-config", "openclaw.json approvals.mode",
|
||||
"config.yaml approvals.mode", "migrated", f"Mapped '{mode}' -> '{hermes_mode}'")
|
||||
|
||||
# Archive full approvals config
|
||||
if len(approvals) > 1 and self.archive_dir:
|
||||
if self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "approvals-config.json"
|
||||
dest.write_text(json.dumps(approvals, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("approvals-config", "openclaw.json approvals (rules)",
|
||||
"archive/approvals-config.json", "archived")
|
||||
|
||||
# ── Memory backend ────────────────────────────────────────
|
||||
def migrate_memory_backend(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
memory = config.get("memory") or {}
|
||||
if not memory:
|
||||
self.record("memory-backend", None, None, "skipped", "No memory backend configuration found")
|
||||
return
|
||||
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "memory-backend-config.json"
|
||||
dest.write_text(json.dumps(memory, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("memory-backend", "openclaw.json memory.*", "archive/memory-backend-config.json",
|
||||
"archived", "Memory backend config (QMD, vector search, citations) archived for manual review")
|
||||
|
||||
# ── Skills config ─────────────────────────────────────────
|
||||
def migrate_skills_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
skills = config.get("skills") or {}
|
||||
entries = skills.get("entries") or {}
|
||||
if not entries and not skills:
|
||||
self.record("skills-config", None, None, "skipped", "No skills registry configuration found")
|
||||
return
|
||||
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "skills-registry-config.json"
|
||||
dest.write_text(json.dumps(skills, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("skills-config", "openclaw.json skills.*", "archive/skills-registry-config.json",
|
||||
"archived", f"Skills registry config ({len(entries)} entries) archived")
|
||||
|
||||
# ── UI / Identity ─────────────────────────────────────────
|
||||
def migrate_ui_identity(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
ui = config.get("ui") or {}
|
||||
if not ui:
|
||||
self.record("ui-identity", None, None, "skipped", "No UI/identity configuration found")
|
||||
return
|
||||
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "ui-identity-config.json"
|
||||
dest.write_text(json.dumps(ui, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("ui-identity", "openclaw.json ui.*", "archive/ui-identity-config.json",
|
||||
"archived", "UI theme and identity settings archived")
|
||||
|
||||
# ── Logging / Diagnostics ─────────────────────────────────
|
||||
def migrate_logging_config(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
config = config or self.load_openclaw_config()
|
||||
logging_cfg = config.get("logging") or {}
|
||||
diagnostics = config.get("diagnostics") or {}
|
||||
combined = {}
|
||||
if logging_cfg:
|
||||
combined["logging"] = logging_cfg
|
||||
if diagnostics:
|
||||
combined["diagnostics"] = diagnostics
|
||||
if not combined:
|
||||
self.record("logging-config", None, None, "skipped", "No logging/diagnostics configuration found")
|
||||
return
|
||||
|
||||
if self.archive_dir and self.execute:
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self.archive_dir / "logging-diagnostics-config.json"
|
||||
dest.write_text(json.dumps(combined, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
self.record("logging-config", "openclaw.json logging/diagnostics",
|
||||
"archive/logging-diagnostics-config.json", "archived")
|
||||
|
||||
# ── Helper: set env var ───────────────────────────────────
|
||||
def _set_env_var(self, key: str, value: str, source_label: str) -> None:
|
||||
env_path = self.target_root / ".env"
|
||||
if self.execute:
|
||||
env_data = parse_env_file(env_path)
|
||||
if key in env_data and not self.overwrite:
|
||||
self.record("env-var", source_label, f".env {key}", "conflict",
|
||||
f"Env var {key} already set")
|
||||
return
|
||||
env_data[key] = value
|
||||
save_env_file(env_path, env_data)
|
||||
self.record("env-var", source_label, f".env {key}", "migrated")
|
||||
|
||||
# ── Generate migration notes ──────────────────────────────
|
||||
def generate_migration_notes(self) -> None:
|
||||
if not self.output_dir:
|
||||
return
|
||||
notes = [
|
||||
"# OpenClaw -> Hermes Migration Notes",
|
||||
"",
|
||||
"This document lists items that require manual attention after migration.",
|
||||
"",
|
||||
"## PM2 / External Processes",
|
||||
"",
|
||||
"Your PM2 processes (Discord bots, Telegram bots, etc.) are NOT affected",
|
||||
"by this migration. They run independently and will continue working.",
|
||||
"No action needed for PM2-managed processes.",
|
||||
"",
|
||||
]
|
||||
|
||||
archived = [i for i in self.items if i.status == "archived"]
|
||||
if archived:
|
||||
notes.extend([
|
||||
"## Archived Items (Manual Review Needed)",
|
||||
"",
|
||||
"These OpenClaw configurations were archived because they don't have a",
|
||||
"direct 1:1 mapping in Hermes. Review each file and recreate manually:",
|
||||
"",
|
||||
])
|
||||
for item in archived:
|
||||
notes.append(f"- **{item.kind}**: `{item.destination}` -- {item.reason}")
|
||||
notes.append("")
|
||||
|
||||
conflicts = [i for i in self.items if i.status == "conflict"]
|
||||
if conflicts:
|
||||
notes.extend([
|
||||
"## Conflicts (Existing Hermes Config Not Overwritten)",
|
||||
"",
|
||||
"These items already existed in your Hermes config. Re-run with",
|
||||
"`--overwrite` to force, or merge manually:",
|
||||
"",
|
||||
])
|
||||
for item in conflicts:
|
||||
notes.append(f"- **{item.kind}**: {item.reason}")
|
||||
notes.append("")
|
||||
|
||||
notes.extend([
|
||||
"## Hermes-Specific Setup",
|
||||
"",
|
||||
"After migration, you may want to:",
|
||||
"- Run `hermes setup` to configure any remaining settings",
|
||||
"- Run `hermes mcp list` to verify MCP servers were imported correctly",
|
||||
"- Run `hermes cron` to recreate scheduled tasks (see archive/cron-config.json)",
|
||||
"- Run `hermes gateway install` if you need the gateway service",
|
||||
"- Review `~/.hermes/config.yaml` for any adjustments",
|
||||
"",
|
||||
])
|
||||
|
||||
if self.execute:
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self.output_dir / "MIGRATION_NOTES.md").write_text(
|
||||
"\n".join(notes) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Migrate OpenClaw user state into Hermes Agent.")
|
||||
@@ -1524,8 +2396,101 @@ def main() -> int:
|
||||
skill_conflict_mode=args.skill_conflict,
|
||||
)
|
||||
report = migrator.migrate()
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
return 0 if report["summary"].get("error", 0) == 0 else 1
|
||||
|
||||
# ── Human-readable terminal recap ─────────────────────────
|
||||
s = report["summary"]
|
||||
items = report["items"]
|
||||
mode_label = "DRY RUN" if not args.execute else "EXECUTED"
|
||||
total = sum(s.values())
|
||||
|
||||
print()
|
||||
print(f" ╔══════════════════════════════════════════════════════╗")
|
||||
print(f" ║ OpenClaw -> Hermes Migration [{mode_label:>8s}] ║")
|
||||
print(f" ╠══════════════════════════════════════════════════════╣")
|
||||
print(f" ║ Source: {str(report['source_root'])[:42]:<42s} ║")
|
||||
print(f" ║ Target: {str(report['target_root'])[:42]:<42s} ║")
|
||||
print(f" ╠══════════════════════════════════════════════════════╣")
|
||||
print(f" ║ ✔ Migrated: {s.get('migrated', 0):>3d} ◆ Archived: {s.get('archived', 0):>3d} ║")
|
||||
print(f" ║ ⊘ Skipped: {s.get('skipped', 0):>3d} ⚠ Conflicts: {s.get('conflict', 0):>3d} ║")
|
||||
print(f" ║ ✖ Errors: {s.get('error', 0):>3d} Total: {total:>3d} ║")
|
||||
print(f" ╚══════════════════════════════════════════════════════╝")
|
||||
|
||||
# Show what was migrated
|
||||
migrated = [i for i in items if i["status"] == "migrated"]
|
||||
if migrated:
|
||||
print()
|
||||
print(" Migrated:")
|
||||
seen_kinds = set()
|
||||
for item in migrated:
|
||||
label = item["kind"]
|
||||
if label in seen_kinds:
|
||||
continue
|
||||
seen_kinds.add(label)
|
||||
dest = item.get("destination") or ""
|
||||
if dest.startswith(str(report["target_root"])):
|
||||
dest = "~/.hermes/" + dest[len(str(report["target_root"])) + 1:]
|
||||
meta = MIGRATION_OPTION_METADATA.get(label, {})
|
||||
display = meta.get("label", label)
|
||||
print(f" ✔ {display:<35s} -> {dest}")
|
||||
|
||||
# Show what was archived
|
||||
archived = [i for i in items if i["status"] == "archived"]
|
||||
if archived:
|
||||
print()
|
||||
print(" Archived (manual review needed):")
|
||||
seen_kinds = set()
|
||||
for item in archived:
|
||||
label = item["kind"]
|
||||
if label in seen_kinds:
|
||||
continue
|
||||
seen_kinds.add(label)
|
||||
reason = item.get("reason", "")
|
||||
meta = MIGRATION_OPTION_METADATA.get(label, {})
|
||||
display = meta.get("label", label)
|
||||
short_reason = reason[:50] + "..." if len(reason) > 50 else reason
|
||||
print(f" ◆ {display:<35s} {short_reason}")
|
||||
|
||||
# Show conflicts
|
||||
conflicts = [i for i in items if i["status"] == "conflict"]
|
||||
if conflicts:
|
||||
print()
|
||||
print(" Conflicts (use --overwrite to force):")
|
||||
for item in conflicts:
|
||||
print(f" ⚠ {item['kind']}: {item.get('reason', '')}")
|
||||
|
||||
# Show errors
|
||||
errors = [i for i in items if i["status"] == "error"]
|
||||
if errors:
|
||||
print()
|
||||
print(" Errors:")
|
||||
for item in errors:
|
||||
print(f" ✖ {item['kind']}: {item.get('reason', '')}")
|
||||
|
||||
# PM2 reassurance
|
||||
print()
|
||||
print(" ℹ PM2 processes (Discord/Telegram bots) are NOT affected.")
|
||||
|
||||
# Next steps
|
||||
if args.execute:
|
||||
print()
|
||||
print(" Next steps:")
|
||||
print(" 1. Review ~/.hermes/config.yaml")
|
||||
print(" 2. Run: hermes mcp list")
|
||||
if any(i["kind"] == "cron-jobs" and i["status"] == "archived" for i in items):
|
||||
print(" 3. Recreate cron jobs: hermes cron")
|
||||
if report.get("output_dir"):
|
||||
print(f" → Full report: {report['output_dir']}/MIGRATION_NOTES.md")
|
||||
elif not args.execute:
|
||||
print()
|
||||
print(" This was a dry run. Add --execute to apply changes.")
|
||||
|
||||
print()
|
||||
|
||||
# Also dump JSON for programmatic use
|
||||
if os.environ.get("MIGRATION_JSON_OUTPUT"):
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||
|
||||
return 0 if s.get("error", 0) == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1009,10 +1009,10 @@ class AIAgent:
|
||||
_compression_cfg = _agent_cfg.get("compression", {})
|
||||
if not isinstance(_compression_cfg, dict):
|
||||
_compression_cfg = {}
|
||||
compression_threshold = float(_compression_cfg.get("threshold", 0.80))
|
||||
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
|
||||
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in ("true", "1", "yes")
|
||||
compression_summary_model = _compression_cfg.get("summary_model") or None
|
||||
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.40))
|
||||
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
|
||||
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
|
||||
|
||||
# Read explicit context_length override from model config
|
||||
|
||||
@@ -519,24 +519,26 @@ class TestSummaryTargetRatio:
|
||||
"""Verify that summary_target_ratio properly scales budgets with context window."""
|
||||
|
||||
def test_tail_budget_scales_with_context(self):
|
||||
"""Tail token budget should be context_length * summary_target_ratio."""
|
||||
"""Tail token budget should be threshold_tokens * summary_target_ratio."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40)
|
||||
assert c.tail_token_budget == 80_000
|
||||
# 200K * 0.50 threshold * 0.40 ratio = 40K
|
||||
assert c.tail_token_budget == 40_000
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40)
|
||||
assert c.tail_token_budget == 400_000
|
||||
# 1M * 0.50 threshold * 0.40 ratio = 200K
|
||||
assert c.tail_token_budget == 200_000
|
||||
|
||||
def test_summary_cap_scales_with_context(self):
|
||||
"""Max summary tokens should be 5% of context, capped at 32K."""
|
||||
"""Max summary tokens should be 5% of context, capped at 12K."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
assert c.max_summary_tokens == 10_000 # 200K * 0.05
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
assert c.max_summary_tokens == 32_000 # capped at ceiling
|
||||
assert c.max_summary_tokens == 12_000 # capped at 12K ceiling
|
||||
|
||||
def test_ratio_clamped(self):
|
||||
"""Ratio should be clamped to [0.10, 0.80]."""
|
||||
@@ -548,12 +550,12 @@ class TestSummaryTargetRatio:
|
||||
c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.95)
|
||||
assert c.summary_target_ratio == 0.80
|
||||
|
||||
def test_default_threshold_is_80_percent(self):
|
||||
"""Default compression threshold should be 80%."""
|
||||
def test_default_threshold_is_50_percent(self):
|
||||
"""Default compression threshold should be 50%."""
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
assert c.threshold_percent == 0.80
|
||||
assert c.threshold_tokens == 80_000
|
||||
assert c.threshold_percent == 0.50
|
||||
assert c.threshold_tokens == 50_000
|
||||
|
||||
def test_default_protect_last_n_is_20(self):
|
||||
"""Default protect_last_n should be 20."""
|
||||
|
||||
@@ -665,11 +665,19 @@ def test_skill_installs_cleanly_under_skills_guard():
|
||||
source="official/migration/openclaw-migration",
|
||||
)
|
||||
|
||||
# The migration script legitimately references AGENTS.md (migrating
|
||||
# workspace instructions), which triggers a false-positive
|
||||
# agent_config_mod finding. Accept "caution" or "safe" — just not
|
||||
# "dangerous" from a *real* threat.
|
||||
# The migration script has several known false-positive findings from the
|
||||
# security scanner. None represent actual threats — they are all legitimate
|
||||
# uses in a migration CLI tool:
|
||||
#
|
||||
# agent_config_mod — references AGENTS.md to migrate workspace instructions
|
||||
# python_os_environ — reads MIGRATION_JSON_OUTPUT to enable JSON output mode
|
||||
# (feature flag, not an env dump)
|
||||
# hermes_config_mod — print statements in the post-migration summary that
|
||||
# tell the user to *review* ~/.hermes/config.yaml;
|
||||
# the script never writes to that file
|
||||
#
|
||||
# Accept "caution" or "safe" — just not "dangerous" from a *real* threat.
|
||||
assert result.verdict in ("safe", "caution", "dangerous"), f"Unexpected verdict: {result.verdict}"
|
||||
# All findings should be the known false-positive for AGENTS.md
|
||||
KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"}
|
||||
for f in result.findings:
|
||||
assert f.pattern_id == "agent_config_mod", f"Unexpected finding: {f}"
|
||||
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"
|
||||
|
||||
@@ -1567,6 +1567,20 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
vision_model = _get_vision_model()
|
||||
logger.debug("browser_vision: analysing screenshot (%d bytes)",
|
||||
len(image_data))
|
||||
|
||||
# Read vision timeout from config (auxiliary.vision.timeout), default 120s.
|
||||
# Local vision models (llama.cpp, ollama) can take well over 30s for
|
||||
# screenshot analysis, so the default must be generous.
|
||||
vision_timeout = 120.0
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
_vt = _cfg.get("auxiliary", {}).get("vision", {}).get("timeout")
|
||||
if _vt is not None:
|
||||
vision_timeout = float(_vt)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
call_kwargs = {
|
||||
"task": "vision",
|
||||
"messages": [
|
||||
@@ -1580,6 +1594,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
],
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.1,
|
||||
"timeout": vision_timeout,
|
||||
}
|
||||
if vision_model:
|
||||
call_kwargs["model"] = vision_model
|
||||
|
||||
@@ -179,6 +179,58 @@ async def _summarize_session(
|
||||
return None
|
||||
|
||||
|
||||
def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:
|
||||
"""Return metadata for the most recent sessions (no LLM calls)."""
|
||||
try:
|
||||
sessions = db.list_sessions_rich(limit=limit + 5) # fetch extra to skip current
|
||||
|
||||
# Resolve current session lineage to exclude it
|
||||
current_root = None
|
||||
if current_session_id:
|
||||
try:
|
||||
sid = current_session_id
|
||||
visited = set()
|
||||
while sid and sid not in visited:
|
||||
visited.add(sid)
|
||||
s = db.get_session(sid)
|
||||
parent = s.get("parent_session_id") if s else None
|
||||
sid = parent if parent else None
|
||||
current_root = max(visited, key=len) if visited else current_session_id
|
||||
except Exception:
|
||||
current_root = current_session_id
|
||||
|
||||
results = []
|
||||
for s in sessions:
|
||||
sid = s.get("id", "")
|
||||
if current_root and (sid == current_root or sid == current_session_id):
|
||||
continue
|
||||
# Skip child/delegation sessions (they have parent_session_id)
|
||||
if s.get("parent_session_id"):
|
||||
continue
|
||||
results.append({
|
||||
"session_id": sid,
|
||||
"title": s.get("title") or None,
|
||||
"source": s.get("source", ""),
|
||||
"started_at": s.get("started_at", ""),
|
||||
"last_active": s.get("last_active", ""),
|
||||
"message_count": s.get("message_count", 0),
|
||||
"preview": s.get("preview", ""),
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"mode": "recent",
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"message": f"Showing {len(results)} most recent sessions. Use a keyword query to search specific topics.",
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logging.error("Error listing recent sessions: %s", e, exc_info=True)
|
||||
return json.dumps({"success": False, "error": f"Failed to list recent sessions: {e}"}, ensure_ascii=False)
|
||||
|
||||
|
||||
def session_search(
|
||||
query: str,
|
||||
role_filter: str = None,
|
||||
@@ -195,11 +247,14 @@ def session_search(
|
||||
if db is None:
|
||||
return json.dumps({"success": False, "error": "Session database not available."}, ensure_ascii=False)
|
||||
|
||||
limit = min(limit, 5) # Cap at 5 sessions to avoid excessive LLM calls
|
||||
|
||||
# Recent sessions mode: when query is empty, return metadata for recent sessions.
|
||||
# No LLM calls — just DB queries for titles, previews, timestamps.
|
||||
if not query or not query.strip():
|
||||
return json.dumps({"success": False, "error": "Query cannot be empty."}, ensure_ascii=False)
|
||||
return _list_recent_sessions(db, limit, current_session_id)
|
||||
|
||||
query = query.strip()
|
||||
limit = min(limit, 5) # Cap at 5 sessions to avoid excessive LLM calls
|
||||
|
||||
try:
|
||||
# Parse role filter
|
||||
@@ -364,8 +419,14 @@ def check_session_search_requirements() -> bool:
|
||||
SESSION_SEARCH_SCHEMA = {
|
||||
"name": "session_search",
|
||||
"description": (
|
||||
"Search your long-term memory of past conversations. This is your recall -- "
|
||||
"Search your long-term memory of past conversations, or browse recent sessions. This is your recall -- "
|
||||
"every past session is searchable, and this tool summarizes what happened.\n\n"
|
||||
"TWO MODES:\n"
|
||||
"1. Recent sessions (no query): Call with no arguments to see what was worked on recently. "
|
||||
"Returns titles, previews, and timestamps. Zero LLM cost, instant. "
|
||||
"Start here when the user asks what were we working on or what did we do recently.\n"
|
||||
"2. Keyword search (with query): Search for specific topics across all past sessions. "
|
||||
"Returns LLM-generated summaries of matching sessions.\n\n"
|
||||
"USE THIS PROACTIVELY when:\n"
|
||||
"- The user says 'we did this before', 'remember when', 'last time', 'as I mentioned'\n"
|
||||
"- The user asks about a topic you worked on before but don't have in current context\n"
|
||||
@@ -385,7 +446,7 @@ SESSION_SEARCH_SCHEMA = {
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query — keywords, phrases, or boolean expressions to find in past sessions.",
|
||||
"description": "Search query — keywords, phrases, or boolean expressions to find in past sessions. Omit this parameter entirely to browse recent sessions instead (returns titles, previews, timestamps with no LLM cost).",
|
||||
},
|
||||
"role_filter": {
|
||||
"type": "string",
|
||||
@@ -397,7 +458,7 @@ SESSION_SEARCH_SCHEMA = {
|
||||
"default": 3,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -410,7 +471,7 @@ registry.register(
|
||||
toolset="session_search",
|
||||
schema=SESSION_SEARCH_SCHEMA,
|
||||
handler=lambda args, **kw: session_search(
|
||||
query=args.get("query", ""),
|
||||
query=args.get("query") or "",
|
||||
role_filter=args.get("role_filter"),
|
||||
limit=args.get("limit", 3),
|
||||
db=kw.get("db"),
|
||||
|
||||
@@ -1050,6 +1050,9 @@ def _get_configured_model() -> str:
|
||||
|
||||
def _resolve_trust_level(source: str) -> str:
|
||||
"""Map a source identifier to a trust level."""
|
||||
# Agent-created skills get their own permissive trust level
|
||||
if source == "agent-created":
|
||||
return "agent-created"
|
||||
# Official optional skills shipped with the repo
|
||||
if source.startswith("official/") or source == "official":
|
||||
return "builtin"
|
||||
|
||||
@@ -325,8 +325,9 @@ async def vision_analyze_tool(
|
||||
logger.info("Processing image with vision model...")
|
||||
|
||||
# Call the vision API via centralized router.
|
||||
# Read timeout from config.yaml (auxiliary.vision.timeout), default 30s.
|
||||
vision_timeout = 30.0
|
||||
# Read timeout from config.yaml (auxiliary.vision.timeout), default 120s.
|
||||
# Local vision models (llama.cpp, ollama) can take well over 30s.
|
||||
vision_timeout = 120.0
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
|
||||
@@ -6,9 +6,20 @@ description: "Run custom code at key lifecycle points — log activity, send ale
|
||||
|
||||
# Event Hooks
|
||||
|
||||
The hooks system lets you run custom code at key points in the agent lifecycle — session creation, slash commands, each tool-calling step, and more. Hooks fire automatically during gateway operation without blocking the main agent pipeline.
|
||||
Hermes has two hook systems that run custom code at key lifecycle points:
|
||||
|
||||
## Creating a Hook
|
||||
| System | Registered via | Runs in | Use case |
|
||||
|--------|---------------|---------|----------|
|
||||
| **[Gateway hooks](#gateway-event-hooks)** | `HOOK.yaml` + `handler.py` in `~/.hermes/hooks/` | Gateway only | Logging, alerts, webhooks |
|
||||
| **[Plugin hooks](#plugin-hooks)** | `ctx.register_hook()` in a [plugin](/docs/user-guide/features/plugins) | CLI + Gateway | Tool interception, metrics, guardrails |
|
||||
|
||||
Both systems are non-blocking — errors in any hook are caught and logged, never crashing the agent.
|
||||
|
||||
## Gateway Event Hooks
|
||||
|
||||
Gateway hooks fire automatically during gateway operation (Telegram, Discord, Slack, WhatsApp) without blocking the main agent pipeline.
|
||||
|
||||
### Creating a Hook
|
||||
|
||||
Each hook is a directory under `~/.hermes/hooks/` containing two files:
|
||||
|
||||
@@ -19,7 +30,7 @@ Each hook is a directory under `~/.hermes/hooks/` containing two files:
|
||||
└── handler.py # Python handler function
|
||||
```
|
||||
|
||||
### HOOK.yaml
|
||||
#### HOOK.yaml
|
||||
|
||||
```yaml
|
||||
name: my-hook
|
||||
@@ -32,7 +43,7 @@ events:
|
||||
|
||||
The `events` list determines which events trigger your handler. You can subscribe to any combination of events, including wildcards like `command:*`.
|
||||
|
||||
### handler.py
|
||||
#### handler.py
|
||||
|
||||
```python
|
||||
import json
|
||||
@@ -58,25 +69,26 @@ async def handle(event_type: str, context: dict):
|
||||
- Can be `async def` or regular `def` — both work
|
||||
- Errors are caught and logged, never crashing the agent
|
||||
|
||||
## Available Events
|
||||
### Available Events
|
||||
|
||||
| Event | When it fires | Context keys |
|
||||
|-------|---------------|--------------|
|
||||
| `gateway:startup` | Gateway process starts | `platforms` (list of active platform names) |
|
||||
| `session:start` | New messaging session created | `platform`, `user_id`, `session_id`, `session_key` |
|
||||
| `session:end` | Session ended (before reset) | `platform`, `user_id`, `session_key` |
|
||||
| `session:reset` | User ran `/new` or `/reset` | `platform`, `user_id`, `session_key` |
|
||||
| `agent:start` | Agent begins processing a message | `platform`, `user_id`, `session_id`, `message` |
|
||||
| `agent:step` | Each iteration of the tool-calling loop | `platform`, `user_id`, `session_id`, `iteration`, `tool_names` |
|
||||
| `agent:end` | Agent finishes processing | `platform`, `user_id`, `session_id`, `message`, `response` |
|
||||
| `command:*` | Any slash command executed | `platform`, `user_id`, `command`, `args` |
|
||||
|
||||
### Wildcard Matching
|
||||
#### Wildcard Matching
|
||||
|
||||
Handlers registered for `command:*` fire for any `command:` event (`command:model`, `command:reset`, etc.). Monitor all slash commands with a single subscription.
|
||||
|
||||
## Examples
|
||||
### Examples
|
||||
|
||||
### Telegram Alert on Long Tasks
|
||||
#### Telegram Alert on Long Tasks
|
||||
|
||||
Send yourself a message when the agent takes more than 10 steps:
|
||||
|
||||
@@ -109,7 +121,7 @@ async def handle(event_type: str, context: dict):
|
||||
)
|
||||
```
|
||||
|
||||
### Command Usage Logger
|
||||
#### Command Usage Logger
|
||||
|
||||
Track which slash commands are used:
|
||||
|
||||
@@ -142,7 +154,7 @@ def handle(event_type: str, context: dict):
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
```
|
||||
|
||||
### Session Start Webhook
|
||||
#### Session Start Webhook
|
||||
|
||||
POST to an external service on new sessions:
|
||||
|
||||
@@ -169,7 +181,7 @@ async def handle(event_type: str, context: dict):
|
||||
}, timeout=5)
|
||||
```
|
||||
|
||||
## How It Works
|
||||
### How It Works
|
||||
|
||||
1. On gateway startup, `HookRegistry.discover_and_load()` scans `~/.hermes/hooks/`
|
||||
2. Each subdirectory with `HOOK.yaml` + `handler.py` is loaded dynamically
|
||||
@@ -178,5 +190,51 @@ async def handle(event_type: str, context: dict):
|
||||
5. Errors in any handler are caught and logged — a broken hook never crashes the agent
|
||||
|
||||
:::info
|
||||
Hooks only fire in the **gateway** (Telegram, Discord, Slack, WhatsApp). The CLI does not currently load hooks.
|
||||
Gateway hooks only fire in the **gateway** (Telegram, Discord, Slack, WhatsApp). The CLI does not load gateway hooks. For hooks that work everywhere, use [plugin hooks](#plugin-hooks).
|
||||
:::
|
||||
|
||||
## Plugin Hooks
|
||||
|
||||
[Plugins](/docs/user-guide/features/plugins) can register hooks that fire in **both CLI and gateway** sessions. These are registered programmatically via `ctx.register_hook()` in your plugin's `register()` function.
|
||||
|
||||
```python
|
||||
def register(ctx):
|
||||
ctx.register_hook("pre_tool_call", my_callback)
|
||||
ctx.register_hook("post_tool_call", my_callback)
|
||||
```
|
||||
|
||||
### Available Plugin Hooks
|
||||
|
||||
| Hook | Fires when | Callback receives |
|
||||
|------|-----------|-------------------|
|
||||
| `pre_tool_call` | Before any tool executes | `tool_name`, `args`, `task_id` |
|
||||
| `post_tool_call` | After any tool returns | `tool_name`, `args`, `result`, `task_id` |
|
||||
| `pre_llm_call` | Before LLM API request | *(planned — not yet wired)* |
|
||||
| `post_llm_call` | After LLM API response | *(planned — not yet wired)* |
|
||||
| `on_session_start` | Session begins | *(planned — not yet wired)* |
|
||||
| `on_session_end` | Session ends | *(planned — not yet wired)* |
|
||||
|
||||
Callbacks receive keyword arguments matching the columns above:
|
||||
|
||||
```python
|
||||
def my_callback(**kwargs):
|
||||
tool = kwargs["tool_name"]
|
||||
args = kwargs["args"]
|
||||
# ...
|
||||
```
|
||||
|
||||
### Example: Block Dangerous Tools
|
||||
|
||||
```python
|
||||
# ~/.hermes/plugins/tool-guard/__init__.py
|
||||
BLOCKED = {"terminal", "write_file"}
|
||||
|
||||
def guard(**kwargs):
|
||||
if kwargs["tool_name"] in BLOCKED:
|
||||
print(f"⚠ Blocked tool call: {kwargs['tool_name']}")
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("pre_tool_call", guard)
|
||||
```
|
||||
|
||||
See the **[Plugins guide](/docs/user-guide/features/plugins)** for full details on creating plugins.
|
||||
|
||||
@@ -46,14 +46,16 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable
|
||||
|
||||
## Available hooks
|
||||
|
||||
Plugins can register callbacks for these lifecycle events. See the **[Event Hooks page](/docs/user-guide/features/hooks#plugin-hooks)** for full details, callback signatures, and examples.
|
||||
|
||||
| Hook | Fires when |
|
||||
|------|-----------|
|
||||
| `pre_tool_call` | Before any tool executes |
|
||||
| `post_tool_call` | After any tool returns |
|
||||
| `pre_llm_call` | Before LLM API request |
|
||||
| `post_llm_call` | After LLM API response |
|
||||
| `on_session_start` | Session begins |
|
||||
| `on_session_end` | Session ends |
|
||||
| `pre_llm_call` | Before LLM API request *(planned)* |
|
||||
| `post_llm_call` | After LLM API response *(planned)* |
|
||||
| `on_session_start` | Session begins *(planned)* |
|
||||
| `on_session_end` | Session ends *(planned)* |
|
||||
|
||||
## Slash commands
|
||||
|
||||
|
||||
Reference in New Issue
Block a user