Skip to content
Open
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
28 changes: 26 additions & 2 deletions acp_adapter/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1229,7 +1229,16 @@ def _run_agent() -> dict:
clear_session_vars,
set_session_vars,
)
session_tokens = set_session_vars(session_key=session_id)
# Extract platform/chat_id from session state for multi-user isolation
session_platform = getattr(state, "platform", "") or "cli"
session_chat_id = getattr(state, "chat_id", "") or ""
session_user_id = getattr(state, "user_id", "") or ""
session_tokens = set_session_vars(
platform=session_platform,
chat_id=session_chat_id,
user_id=session_user_id,
session_key=session_id,
)
except Exception:
session_tokens = None
clear_session_vars = None # type: ignore[assignment]
Expand Down Expand Up @@ -1698,12 +1707,27 @@ async def set_session_mode(
async def set_config_option(
self, config_id: str, session_id: str, value: str, **kwargs: Any
) -> SetSessionConfigOptionResponse | None:
"""Accept ACP config option updates even when Hermes has no typed ACP config surface yet."""
"""Accept ACP config option updates to set platform/chat_id for multi-user routing."""
state = self.session_manager.get_session(session_id)
if state is None:
logger.warning("Session %s: config update requested for missing session", session_id)
return None

# Handle multi-user isolation config options
if config_id in ("chat_id", "platform", "user_id"):
if config_id == "chat_id":
state.chat_id = str(value)
logger.info("Session %s: chat_id set to %s", session_id, value)
elif config_id == "platform":
state.platform = str(value)
logger.info("Session %s: platform set to %s", session_id, value)
elif config_id == "user_id":
state.user_id = str(value)
logger.info("Session %s: user_id set to %s", session_id, value)
self.session_manager.save_session(session_id)
return SetSessionConfigOptionResponse(config_options=[])

# Legacy: store arbitrary config options
options = getattr(state, "config_options", None)
if not isinstance(options, dict):
options = {}
Expand Down
24 changes: 24 additions & 0 deletions acp_adapter/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ class SessionState:
runtime_lock: Any = field(default_factory=Lock)
current_prompt_text: str = ""
interrupted_prompt_text: str = ""
# Multi-user isolation: platform/chat_id for routing send_message to the correct user
platform: str = "" # e.g. "feishu", "cli"
chat_id: str = "" # The chat/channel to send notifications to
user_id: str = "" # Optional user identifier


class SessionManager:
Expand Down Expand Up @@ -272,6 +276,10 @@ def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState
model=getattr(agent, "model", original.model) or original.model,
history=copy.deepcopy(original.history),
cancel_event=threading.Event(),
# Inherit multi-user isolation context from parent session
platform=original.platform,
chat_id=original.chat_id,
user_id=original.user_id,
)
with self._lock:
self._sessions[new_id] = state
Expand Down Expand Up @@ -442,6 +450,13 @@ def _persist(self, state: SessionState) -> None:
session_meta["base_url"] = base_url.strip()
if isinstance(api_mode, str) and api_mode.strip():
session_meta["api_mode"] = api_mode.strip()
# Multi-user isolation fields
if state.platform:
session_meta["platform"] = state.platform
if state.chat_id:
session_meta["chat_id"] = state.chat_id
if state.user_id:
session_meta["user_id"] = state.user_id
cwd_json = json.dumps(session_meta)

try:
Expand Down Expand Up @@ -499,6 +514,9 @@ def _restore(self, session_id: str) -> Optional[SessionState]:
requested_provider = row.get("billing_provider")
restored_base_url = row.get("billing_base_url")
restored_api_mode = None
restored_platform = ""
restored_chat_id = ""
restored_user_id = ""
mc = row.get("model_config")
if mc:
try:
Expand All @@ -508,6 +526,9 @@ def _restore(self, session_id: str) -> Optional[SessionState]:
requested_provider = meta.get("provider") or requested_provider
restored_base_url = meta.get("base_url") or restored_base_url
restored_api_mode = meta.get("api_mode") or restored_api_mode
restored_platform = meta.get("platform", "")
restored_chat_id = meta.get("chat_id", "")
restored_user_id = meta.get("user_id", "")
except (json.JSONDecodeError, TypeError):
pass

Expand Down Expand Up @@ -540,6 +561,9 @@ def _restore(self, session_id: str) -> Optional[SessionState]:
model=model or getattr(agent, "model", "") or "",
history=history,
cancel_event=threading.Event(),
platform=restored_platform,
chat_id=restored_chat_id,
user_id=restored_user_id,
)
with self._lock:
self._sessions[session_id] = state
Expand Down
57 changes: 55 additions & 2 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,18 @@
_FEISHU_CONNECT_ATTEMPTS = 3
_FEISHU_SEND_ATTEMPTS = 3
_FEISHU_APP_LOCK_SCOPE = "feishu-app-id"
# Disable lark-oapi's internal WS auto-reconnect so we can manage reconnection
# ourselves with proper gateway-level observability. The internal reconnect
# runs in the same thread and is invisible to the gateway; if it fails the
# whole thread dies silently. By disabling it we get a clean error from the
# WS client and can react through the done-callback below.
# Whether lark-oapi's internal reconnect is disabled.
# Default False: lark-oapi retries on its own; we only escalate when it gives up.
# Set True only for debugging reconnect behaviour.
_FEISHU_WS_DISABLE_INTERNAL_RECONNECT = False
# Heartbeat: how many seconds without any WS message before we declare the connection dead.
# Lark WS ping interval is 120s; use 90s so we fire ~30s before the server kills it.
_FEISHU_WS_HEARTBEAT_INTERVAL = 90
_DEFAULT_TEXT_BATCH_DELAY_SECONDS = 0.6
_DEFAULT_TEXT_BATCH_MAX_MESSAGES = 8
_DEFAULT_TEXT_BATCH_MAX_CHARS = 4000
Expand Down Expand Up @@ -388,6 +400,7 @@ class FeishuAdapterSettings:
ws_reconnect_interval: int = 120
ws_ping_interval: Optional[int] = None
ws_ping_timeout: Optional[int] = None
ws_heartbeat_interval: int = 90
admins: frozenset[str] = frozenset()
default_group_policy: str = ""
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
Expand Down Expand Up @@ -1297,6 +1310,11 @@ def _apply_runtime_ws_overrides() -> None:
setattr(ws_client, "_reconnect_interval", adapter._ws_reconnect_interval)
if adapter._ws_ping_interval is not None:
setattr(ws_client, "_ping_interval", adapter._ws_ping_interval)
# Disable lark-oapi's internal reconnect so the gateway can manage
# reconnection with full observability. Without this the internal
# reconnect thread silently dies and the gateway doesn't notice.
if _FEISHU_WS_DISABLE_INTERNAL_RECONNECT:
setattr(ws_client, "_auto_reconnect", False)
except Exception:
logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True)

Expand All @@ -1314,14 +1332,27 @@ def _configure_with_overrides(conf: Any) -> Any:
_apply_runtime_ws_overrides()
return result

# lark-oapi manages its own reconnect internally. We don't interfere with that.
# Instead, we rely on done_callback to detect genuine failures (SSL errors that
# lark-oapi can't recover from) and trigger a gateway-level reconnect.
ws_client_module.websockets.connect = _connect_with_overrides
if original_configure is not None:
setattr(ws_client, "_configure", _configure_with_overrides)
_apply_runtime_ws_overrides()
try:
ws_client.start()
except Exception:
pass
except BaseException:
# Exceptions propagate to the executor Future so done_callback can react.
# "Event loop is running" from loop.stop() is harmless — swallow it.
import sys as _sys_be
exc_info = _sys_be.exc_info()
if exc_info[0] is RuntimeError and exc_info[1] and "is running" in str(exc_info[1]).lower():
pass # lark-oapi reconnect cleanup; not a real error
elif exc_info[0] in (KeyboardInterrupt, SystemExit):
raise
# All other BaseExceptions (SSL, network, real errors) are swallowed here.
# The lark-oapi logger already printed details. We don't want to crash
# the executor thread; the done_callback handles real errors.
finally:
ws_client_module.websockets.connect = original_connect
if original_configure is not None:
Expand Down Expand Up @@ -1520,6 +1551,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
ws_reconnect_interval=_coerce_required_int(extra.get("ws_reconnect_interval"), default=120, min_value=1),
ws_ping_interval=_coerce_int(extra.get("ws_ping_interval"), default=None, min_value=1),
ws_ping_timeout=_coerce_int(extra.get("ws_ping_timeout"), default=None, min_value=1),
ws_heartbeat_interval=_coerce_required_int(extra.get("ws_heartbeat_interval"), default=90, min_value=30),
admins=admins,
default_group_policy=default_group_policy,
group_rules=group_rules,
Expand Down Expand Up @@ -1557,6 +1589,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
self._ws_reconnect_interval = settings.ws_reconnect_interval
self._ws_ping_interval = settings.ws_ping_interval
self._ws_ping_timeout = settings.ws_ping_timeout
self._ws_heartbeat_interval = settings.ws_heartbeat_interval
self._allow_bots = settings.allow_bots
self._require_mention = settings.require_mention

Expand Down Expand Up @@ -4404,6 +4437,26 @@ async def _connect_websocket(self) -> None:
self._ws_client,
self,
)
# Monitor the WS thread: if it dies with a real error (SSL fatal, auth failure,
# network unreachable after all retries) the gateway needs to know so it can
# trigger its own reconnect. Note: lark-oapi's internal reconnect is enabled,
# so most transient errors are handled silently. We only escalate genuine failures.
def _ws_thread_done(fut: asyncio.Future) -> None:
exc = fut.exception()
if exc is None:
# Normal exit — WS thread shut down cleanly (e.g. disconnect() called).
return
msg = f"[Feishu] WebSocket thread exited with error: {exc}"
logger.error("%s", msg)
if self._loop is None or self._loop.is_closed():
return
# Real error that lark-oapi couldn't recover from — trigger gateway reconnect.
asyncio.run_coroutine_threadsafe(
self._notify_fatal_error(),
self._loop,
)

self._ws_future.add_done_callback(_ws_thread_done)

async def _connect_webhook(self) -> None:
if not FEISHU_WEBHOOK_AVAILABLE:
Expand Down
94 changes: 94 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,9 @@ def _resolve_session_agent_runtime(
If the session override already contains a complete provider bundle
(provider/api_key/base_url/api_mode), prefer it directly instead of
resolving fresh global runtime state first.

Also checks HERMES_OS_MODEL_TIER env var for HermesOS model routing
(minimax/blend/baosi -> corresponding provider config).
"""
resolved_session_key = session_key
if not resolved_session_key and source is not None:
Expand All @@ -1800,6 +1803,25 @@ def _resolve_session_agent_runtime(
except Exception:
resolved_session_key = None

# Check for HermesOS model_tier override (set by HermesOS gateway_hook)
model_tier = os.getenv("HERMES_OS_MODEL_TIER")
if model_tier:
tier_config = self._resolve_model_tier_config(model_tier)
if tier_config:
logger.info(
"[HermesOS] Model tier override: %s -> model=%s provider=%s",
model_tier, tier_config.get("model"), tier_config.get("provider"),
)
return tier_config.get("model", "claude-sonnet-4-6"), {
"provider": tier_config.get("provider"),
"api_key": tier_config.get("api_key"),
"base_url": tier_config.get("base_url"),
"api_mode": tier_config.get("api_mode"),
"command": None,
"args": [],
"credential_pool": None,
}

model = _resolve_gateway_model(user_config)
override = self._session_model_overrides.get(resolved_session_key) if resolved_session_key else None
if override:
Expand Down Expand Up @@ -1862,6 +1884,65 @@ def _resolve_session_agent_runtime(

return model, runtime_kwargs

def _resolve_model_tier_config(self, model_tier: str) -> dict | None:
"""Resolve provider config for a HermesOS model_tier (minimax/blend/baosi).

Reads from config.yaml providers section and fallback_providers.
Returns dict with model/provider/base_url/api_key/api_mode or None if not found.
"""
try:
import yaml
cfg_path = _hermes_home / "config.yaml"
if not cfg_path.exists():
return None
with open(cfg_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}

# First check providers section
providers = cfg.get("providers", {})
tier_to_key = {
# HermesOS Sovereign Logic Tiers → config.yaml provider key
"local": "local", # Ollama qwen3.6:27b → localhost:11434
"kilo": "kilocode", # Kilo free gateway → api.kilo.ai
"minimax": "minimax-cn", # MiniMax-M2.7 → api.minimaxi.com
"blend": "blend", # blend → localhost:8000
"opus": "opus", # Claude Opus → Baosi
"baosi": "baosi", # Baosi claude-sonnet-4-6 → api.baosiapi.com
}
provider_key = tier_to_key.get(model_tier)
if provider_key and provider_key in providers:
p = providers[provider_key]
return {
"model": p.get("model", ""),
"provider": p.get("provider", "custom"),
"base_url": p.get("base_url", ""),
"api_key": p.get("api_key", ""),
"api_mode": p.get("api_mode", "chat_completions"),
}

# Fallback: check fallback_providers for model_tier match
fb_list = cfg.get("fallback_providers") or []
if not isinstance(fb_list, list):
fb_list = [fb_list] if isinstance(fb_list, dict) else []
for entry in fb_list:
if not isinstance(entry, dict):
continue
# Match by model name containing the tier
model_name = entry.get("model", "").lower()
if model_tier.lower() in model_name or model_tier.lower() in entry.get("provider", "").lower():
return {
"model": entry.get("model", ""),
"provider": entry.get("provider", "custom"),
"base_url": entry.get("base_url", ""),
"api_key": entry.get("api_key", ""),
"api_mode": entry.get("api_mode", "chat_completions"),
}

return None
except Exception as e:
logger.debug("[HermesOS] Failed to resolve model tier %s: %s", model_tier, e)
return None

def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
"""Build the effective model/runtime config for a single turn.

Expand Down Expand Up @@ -16416,6 +16497,19 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
if _stderr_level < logging.getLogger().level:
logging.getLogger().setLevel(_stderr_level)

# Apply Hermes OS gateway patch — injects per-user context into session prompts.
# Must happen before GatewayRunner() is instantiated so the hook system and
# session-scoped context store are wired up before any message is processed.
try:
import sys as _sys
_patch_src = str(Path.home() / "hermes-os" / "src")
if _patch_src not in _sys.path:
_sys.path.insert(0, _patch_src)
from hermes_os.gateway_patch import apply_patch as _apply_hermes_os_patch
_apply_hermes_os_patch()
except Exception as _e:
print(f"[hermes-os] gateway patch skipped (harmless if hermes-os not installed): {_e}", flush=True)

runner = GatewayRunner(config)

# Track whether an unexpected signal initiated the shutdown. When an
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ class ProviderConfig:
name="MiniMax (China)",
auth_type="api_key",
inference_base_url="https://api.minimaxi.com/anthropic",
api_key_env_vars=("MINIMAX_CN_API_KEY",),
api_key_env_vars=("MINIMAX_CN_API_KEY", "MINIMAX_CN_API_KEY_2"),
base_url_env_var="MINIMAX_CN_BASE_URL",
),
"deepseek": ProviderConfig(
Expand Down
Loading