AST-driven, body-identical move of 359 GatewayRunner methods into cohesive
mixin modules (gateway/run_{voice,adapters,topics,turn,shutdown,busy,
config_loaders,startup,watchers,notifications,inbound,goals,agent_cache}.py)
plus TurnRunner -> gateway/run_turn_runner.py. run.py-internal symbols are
imported lazily inside method bodies so patch('gateway.run.X') keeps
intercepting; neutral deps are top-level; logger name stays 'gateway.run'.
_UNSET moved to leaf gateway/run_common.py (def-time default-arg sentinel).
Whole-module inspect.getsource(gateway_run) AST-walker tests repointed to
the module that now holds the walked code.
5938 lines
284 KiB
Python
5938 lines
284 KiB
Python
"""Agent-turn execution (_handle_message_with_agent, _run_agent*, proxy path, background tasks, MCP reload) for GatewayRunner.
|
||
|
||
Split out of ``gateway/run.py``; bound onto ``GatewayRunner`` via the MRO.
|
||
``gateway.run`` internals are imported lazily inside method bodies (import cycle),
|
||
so ``patch("gateway.run.X")`` keeps intercepting them at call time.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import TYPE_CHECKING
|
||
import asyncio
|
||
import dataclasses
|
||
import inspect
|
||
import json
|
||
import os
|
||
import queue
|
||
import threading
|
||
import time
|
||
from agent.i18n import t
|
||
from contextlib import suppress
|
||
from contextvars import copy_context
|
||
from gateway.config import Platform
|
||
from gateway.media_repair import repair_explicit_computer_use_media_paths
|
||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent
|
||
from gateway.session import (
|
||
SessionSource,
|
||
TranscriptReadError,
|
||
_session_key_namespace,
|
||
build_channel_continuity_note,
|
||
build_session_context,
|
||
)
|
||
from gateway.turn_context import TurnContext
|
||
from gateway.turn_lease import DEFAULT_LEASE_WAIT, TurnLeaseTimeoutError
|
||
from hermes_constants import get_hermes_home_override
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||
from utils import base_url_hostname
|
||
|
||
if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
|
||
from gateway.run import GatewayRunner, TurnRunner # noqa: F401
|
||
|
||
# Log-record parity with the origin module.
|
||
logger = logging.getLogger("gateway.run")
|
||
|
||
|
||
class GatewayTurnMixin:
|
||
"""Agent-turn execution (_handle_message_with_agent, _run_agent*, proxy path, background tasks, MCP reload) for GatewayRunner."""
|
||
|
||
def _resolve_session_agent_runtime(
|
||
self,
|
||
*,
|
||
source: Optional[SessionSource] = None,
|
||
session_key: Optional[str] = None,
|
||
user_config: Optional[dict] = None,
|
||
) -> tuple[str, dict]:
|
||
"""Resolve model/runtime for a session.
|
||
|
||
Priority (highest first): session ``/model`` → ``channel_overrides`` → global config/env
|
||
(``_resolve_gateway_model(user_config)`` and default provider resolution).
|
||
"""
|
||
from gateway.run import (
|
||
_credential_pool_for_provider,
|
||
_get_channel_override,
|
||
_resolve_gateway_model,
|
||
_resolve_runtime_agent_kwargs,
|
||
_resolve_runtime_agent_kwargs_for_provider,
|
||
)
|
||
resolved_session_key = self._resolve_session_key_or_none(source, session_key)
|
||
|
||
model = _resolve_gateway_model(user_config)
|
||
if resolved_session_key:
|
||
self._rehydrate_session_model_override(resolved_session_key)
|
||
_override_state = (
|
||
self._peek_session_state(resolved_session_key)
|
||
if resolved_session_key
|
||
else None
|
||
)
|
||
override = (
|
||
_override_state.conversation.model_override if _override_state else None
|
||
)
|
||
if override:
|
||
override_model = override.get("model", model)
|
||
override_runtime = {
|
||
"provider": override.get("provider"),
|
||
"requested_provider": override.get("requested_provider"),
|
||
"api_key": override.get("api_key"),
|
||
"base_url": override.get("base_url"),
|
||
"api_mode": override.get("api_mode"),
|
||
"max_tokens": override.get("max_tokens"),
|
||
"credential_pool": override.get("credential_pool"),
|
||
"request_overrides": override.get("request_overrides"),
|
||
"capabilities": dict(override.get("capabilities") or {}),
|
||
}
|
||
if override_runtime.get("api_key"):
|
||
if override_runtime.get("credential_pool") is None:
|
||
override_runtime["credential_pool"] = _credential_pool_for_provider(
|
||
override.get("provider")
|
||
)
|
||
logger.debug(
|
||
"Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s",
|
||
resolved_session_key or "", model, override_model,
|
||
override_runtime.get("provider"),
|
||
)
|
||
return override_model, override_runtime
|
||
# Override exists but has no api_key — fall through to env-based
|
||
# resolution and apply model/provider from the override on top.
|
||
logger.debug(
|
||
"Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s",
|
||
resolved_session_key or "", model, override_model,
|
||
)
|
||
else:
|
||
logger.debug(
|
||
"No session model override: session=%s config_model=%s override_keys=%s",
|
||
resolved_session_key or "", model,
|
||
[
|
||
_key
|
||
for _key, _st in list(self._sessions_map().items())
|
||
if _st.conversation.model_override is not None
|
||
][:5] or "[]",
|
||
)
|
||
|
||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||
runtime_model = runtime_kwargs.pop("model", None)
|
||
if runtime_model:
|
||
logger.info(
|
||
"Runtime provider supplied explicit model override: %s -> %s",
|
||
model,
|
||
runtime_model,
|
||
)
|
||
model = runtime_model
|
||
|
||
cfg = getattr(self, "config", None)
|
||
if cfg and source is not None:
|
||
chat_id = str(source.chat_id) if source.chat_id else ""
|
||
thread_id = (
|
||
str(source.thread_id) if getattr(source, "thread_id", None) else None
|
||
)
|
||
parent_id = (
|
||
str(source.parent_chat_id)
|
||
if getattr(source, "parent_chat_id", None)
|
||
else None
|
||
)
|
||
ch = _get_channel_override(
|
||
cfg,
|
||
source.platform,
|
||
chat_id,
|
||
thread_id=thread_id,
|
||
parent_id=parent_id,
|
||
)
|
||
if ch:
|
||
if ch.model:
|
||
model = ch.model
|
||
if ch.provider:
|
||
runtime_kwargs = _resolve_runtime_agent_kwargs_for_provider(
|
||
ch.provider
|
||
)
|
||
ch_runtime_model = runtime_kwargs.pop("model", None)
|
||
# Only adopt the provider's bundled model when the override
|
||
# did not specify an explicit model.
|
||
if ch_runtime_model and not ch.model:
|
||
model = ch_runtime_model
|
||
|
||
if override and resolved_session_key:
|
||
model, runtime_kwargs = self._apply_session_model_override(
|
||
resolved_session_key, model, runtime_kwargs
|
||
)
|
||
|
||
# No model.default but a provider resolved (e.g. `hermes auth add openai-codex` without
|
||
# `hermes model`): fall back to the provider's first catalog model so the API call has one.
|
||
if not model and runtime_kwargs.get("provider"):
|
||
try:
|
||
from hermes_cli.models import get_default_model_for_provider
|
||
model = get_default_model_for_provider(runtime_kwargs["provider"])
|
||
if model:
|
||
logger.info(
|
||
"No model configured — defaulting to %s for provider %s",
|
||
model, runtime_kwargs["provider"],
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Final safety net: if resolution still produced an empty model (e.g. a transient config-cache
|
||
# miss on a post-interrupt recovery turn), reuse the last model resolved for this session,
|
||
# else the most recent process-wide — model="" makes every API call fail HTTP 400 and the
|
||
# session goes silent. ``getattr`` guards bare test runners built via ``object.__new__``.
|
||
if not model:
|
||
_lr_state = (
|
||
self._peek_session_state(resolved_session_key)
|
||
if resolved_session_key
|
||
else None
|
||
)
|
||
_lr_star = self._peek_session_state("*")
|
||
_recovered = (
|
||
(_lr_state.conversation.last_resolved_model if _lr_state else "")
|
||
or (_lr_star.conversation.last_resolved_model if _lr_star else "")
|
||
)
|
||
if _recovered:
|
||
logger.warning(
|
||
"Empty model resolved for session=%s — recovering "
|
||
"last-known-good model %s (config read likely returned "
|
||
"empty; see #35314)",
|
||
resolved_session_key or "", _recovered,
|
||
)
|
||
model = _recovered
|
||
elif model:
|
||
# Cache the good resolution for future recovery turns.
|
||
if resolved_session_key:
|
||
self._session_state(
|
||
resolved_session_key
|
||
).conversation.last_resolved_model = model
|
||
self._session_state("*").conversation.last_resolved_model = model
|
||
|
||
return model, runtime_kwargs
|
||
|
||
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.
|
||
|
||
Always uses the session's primary model/provider. If `/fast` is enabled and the model
|
||
supports it, attach `request_overrides` for priority processing. Per-provider
|
||
``request_overrides`` from ``resolve_runtime_provider`` (e.g. ``custom_providers``
|
||
``extra_body``) are merged *under* the fast-mode overrides so they still reach the model.
|
||
"""
|
||
from gateway.run import _deep_merge_request_overrides
|
||
from hermes_cli.models import resolve_fast_mode_overrides
|
||
|
||
runtime = {
|
||
"api_key": runtime_kwargs.get("api_key"),
|
||
"base_url": runtime_kwargs.get("base_url"),
|
||
"provider": runtime_kwargs.get("provider"),
|
||
"requested_provider": runtime_kwargs.get("requested_provider"),
|
||
"api_mode": runtime_kwargs.get("api_mode"),
|
||
"command": runtime_kwargs.get("command"),
|
||
"args": list(runtime_kwargs.get("args") or []),
|
||
"credential_pool": runtime_kwargs.get("credential_pool"),
|
||
"max_tokens": runtime_kwargs.get("max_tokens"),
|
||
"capabilities": dict(runtime_kwargs.get("capabilities") or {}),
|
||
}
|
||
base_request_overrides = dict(runtime_kwargs.get("request_overrides") or {})
|
||
route = {
|
||
"model": model,
|
||
"runtime": runtime,
|
||
"signature": (
|
||
model,
|
||
runtime["provider"],
|
||
runtime["requested_provider"],
|
||
runtime["base_url"],
|
||
runtime["api_mode"],
|
||
runtime["command"],
|
||
tuple(runtime["args"]),
|
||
),
|
||
}
|
||
|
||
# Provider-level request_overrides (e.g. a custom_providers extra_body) resolved upstream by
|
||
# resolve_runtime_provider().
|
||
service_tier = getattr(self, "_service_tier", None)
|
||
if service_tier != "priority":
|
||
# None (normal) or auto/cold — the bounded window is applied per
|
||
# request by agent.fast_mode, not pinned into request_overrides.
|
||
route["request_overrides"] = base_request_overrides
|
||
return route
|
||
|
||
try:
|
||
overrides = resolve_fast_mode_overrides(
|
||
route["model"],
|
||
provider=runtime["provider"],
|
||
base_url=runtime["base_url"],
|
||
)
|
||
except Exception:
|
||
overrides = None
|
||
# Fast-mode overrides (service_tier / speed) are top-level keys and do
|
||
# not collide with extra_body; deep-merge them over the provider overrides.
|
||
route["request_overrides"] = _deep_merge_request_overrides(
|
||
base_request_overrides,
|
||
overrides or {},
|
||
)
|
||
return route
|
||
|
||
def _sync_session_model_from_agent(self, session_id: str, agent: Any) -> None:
|
||
"""Persist the runtime model/provider actually used by a gateway turn.
|
||
|
||
Provider fallback can switch ``agent.model``/``agent.provider`` after the session row was
|
||
created; keep the DB metadata in sync so session lists and tooling report the backend that
|
||
actually answered. Runs in the ``run_sync`` closure (executor thread), so it uses the sync
|
||
``SessionDB`` (``_db``) directly rather than the AsyncSessionDB forwarder.
|
||
"""
|
||
if not session_id or agent is None or self._session_db is None:
|
||
return
|
||
model = getattr(agent, "model", None)
|
||
if not model:
|
||
return
|
||
runtime = {
|
||
"provider": getattr(agent, "provider", None),
|
||
"base_url": getattr(agent, "base_url", None),
|
||
"api_mode": getattr(agent, "api_mode", None),
|
||
"fallback_active": bool(getattr(agent, "_fallback_activated", False)),
|
||
}
|
||
runtime = {k: v for k, v in runtime.items() if v not in (None, "")}
|
||
|
||
try:
|
||
db = self._session_db._db
|
||
row = db.get_session(session_id)
|
||
if not row:
|
||
return
|
||
current_model = row.get("model")
|
||
raw_config = row.get("model_config")
|
||
try:
|
||
config = json.loads(raw_config) if raw_config else {}
|
||
except Exception:
|
||
config = {}
|
||
if not isinstance(config, dict):
|
||
config = {}
|
||
gateway_runtime = dict(config.get("gateway_runtime") or {})
|
||
if current_model == model and all(
|
||
gateway_runtime.get(k) == v for k, v in runtime.items()
|
||
):
|
||
return
|
||
config["gateway_runtime"] = runtime
|
||
db.update_session_meta(session_id, json.dumps(config), model=model)
|
||
except Exception:
|
||
logger.debug("Failed to sync gateway session model metadata", exc_info=True)
|
||
|
||
async def _hmwa_resolve_session(self, event, source):
|
||
"""Resolve ``source`` to its session entry (topic recovery, internal-route guards, Telegram
|
||
topic-binding heal). Returns ``(source, session_entry, session_key)`` or ``None`` to drop
|
||
the event."""
|
||
# Get or create session Topic-mode DMs: rewrite a stale/foreign thread_id to the user's
|
||
# last-active topic so a cross-topic Reply or stripped plain reply doesn't fragment the
|
||
# conversation across sessions.
|
||
recovered = await asyncio.to_thread(self._recover_telegram_topic_thread_id, source)
|
||
if recovered is not None:
|
||
logger.info(
|
||
"telegram topic recovery: chat=%s user=%s %r -> %s",
|
||
source.chat_id, source.user_id, source.thread_id, recovered,
|
||
)
|
||
source = dataclasses.replace(source, thread_id=recovered)
|
||
with suppress(Exception):
|
||
event.source = source
|
||
|
||
event_metadata = getattr(event, "metadata", None) or {}
|
||
expected_session_key = str(
|
||
event_metadata.get("gateway_session_key") or ""
|
||
).strip()
|
||
if expected_session_key:
|
||
derived_session_key = self._session_key_for_source(source)
|
||
if derived_session_key != expected_session_key:
|
||
logger.warning(
|
||
"Dropping internally routed event after route recovery: "
|
||
"expected session=%s derived=%s",
|
||
expected_session_key,
|
||
derived_session_key,
|
||
)
|
||
return
|
||
|
||
strict_session = bool(event_metadata.get("gateway_session_strict"))
|
||
pinned_session_id = str(event_metadata.get("gateway_session_id") or "").strip()
|
||
if strict_session:
|
||
session_entry = await self.async_session_store.lookup_by_session_key(
|
||
expected_session_key
|
||
)
|
||
if (
|
||
session_entry is None
|
||
or not pinned_session_id
|
||
or session_entry.session_id != pinned_session_id
|
||
):
|
||
logger.warning(
|
||
"Dropping internally routed event: expected session id=%s is no "
|
||
"longer current for key=%s",
|
||
pinned_session_id or "missing",
|
||
expected_session_key or "missing",
|
||
)
|
||
return
|
||
else:
|
||
# Internal wakes must observe reset policy without becoming user activity themselves.
|
||
# Otherwise periodic Kanban/process notifications keep the stable routing key alive
|
||
# across every daily/idle boundary.
|
||
session_entry = await self.async_session_store.get_or_create_session(
|
||
source,
|
||
touch_activity=not bool(getattr(event, "internal", False)),
|
||
)
|
||
session_key = session_entry.session_key
|
||
if not strict_session and pinned_session_id:
|
||
resolved_entry = await self._resolve_async_delegation_session(
|
||
session_entry,
|
||
pinned_session_id,
|
||
)
|
||
if resolved_entry is None:
|
||
return
|
||
session_entry = resolved_entry
|
||
self._cache_session_source(session_key, source)
|
||
if await asyncio.to_thread(self._is_telegram_topic_lane, source):
|
||
try:
|
||
binding = (await self._session_db.get_telegram_topic_binding(
|
||
chat_id=str(source.chat_id),
|
||
thread_id=str(source.thread_id),
|
||
profile_name=self._telegram_topic_profile_name(source),
|
||
)) if self._session_db else None
|
||
except Exception:
|
||
logger.debug("Failed to read Telegram topic binding", exc_info=True)
|
||
binding = None
|
||
if binding:
|
||
bound_session_id = str(binding.get("session_id") or "")
|
||
# Heal bindings that point at a pre-compression parent: walk the compression-
|
||
# continuation chain forward to its tip so the next message resumes the compressed
|
||
# child instead of reloading the oversized parent transcript.
|
||
if bound_session_id and self._session_db is not None:
|
||
try:
|
||
canonical_session_id = await self._session_db.get_compression_tip(
|
||
bound_session_id,
|
||
)
|
||
except Exception:
|
||
logger.debug(
|
||
"compression-tip lookup failed for %s",
|
||
bound_session_id, exc_info=True,
|
||
)
|
||
canonical_session_id = bound_session_id
|
||
if (
|
||
canonical_session_id
|
||
and canonical_session_id != bound_session_id
|
||
):
|
||
bound_session_id = canonical_session_id
|
||
if bound_session_id and bound_session_id != session_entry.session_id:
|
||
# Route the override through SessionStore so the session_key → session_id
|
||
# mapping is persisted to disk and the previous lane session is ended cleanly.
|
||
# Mutating session_entry in place created a split-brain: the JSON index pointed
|
||
# at one id while downstream code used another.
|
||
switched = await self.async_session_store.switch_session(session_key, bound_session_id)
|
||
if switched is not None:
|
||
session_entry = switched
|
||
# If the stored binding pointed at a parent, rewrite it to the
|
||
# canonical descendant now that we've followed the chain.
|
||
if (
|
||
bound_session_id
|
||
and bound_session_id != str(binding.get("session_id") or "")
|
||
):
|
||
await asyncio.to_thread(
|
||
self._sync_telegram_topic_binding,
|
||
source, session_entry, reason="compression-tip-walk",
|
||
)
|
||
else:
|
||
try:
|
||
await asyncio.to_thread(self._record_telegram_topic_binding, source, session_entry)
|
||
except Exception:
|
||
logger.debug("Failed to record Telegram topic binding", exc_info=True)
|
||
return source, session_entry, session_key
|
||
|
||
async def _hmwa_open_session(self, session_entry, session_key, source):
|
||
"""Consume auto-reset / fresh-reset flags and emit ``session:start`` for new sessions.
|
||
Returns ``(_was_auto_reset, _is_new_session)``."""
|
||
# Capture and consume was_auto_reset immediately so it cannot re-fire on later messages and
|
||
# wipe model/reasoning overrides set between turns.
|
||
_was_auto_reset = getattr(session_entry, "was_auto_reset", False)
|
||
if _was_auto_reset:
|
||
# Auto-reset is a full conversation boundary: one funnel call clears every conversation-
|
||
# scoped per-session dict so the fresh session inherits no model/reasoning overrides, no
|
||
# queued "/model switched" note and no stale resolved-model cache.
|
||
self._clear_conversation_scope(session_key, reason="auto_reset")
|
||
# Evict the cached agent: the cache is keyed on the stable session_key, so an auto-reset
|
||
# would otherwise reuse the old agent and leak context_compressor._previous_summary
|
||
# (prior history) into new compaction summaries.
|
||
self._evict_cached_agent(session_key)
|
||
session_entry.was_auto_reset = False
|
||
|
||
# Emit session:start for new or auto-reset sessions
|
||
_is_new_session = (
|
||
session_entry.created_at == session_entry.updated_at
|
||
or _was_auto_reset
|
||
or getattr(session_entry, "is_fresh_reset", False)
|
||
)
|
||
# Consume the is_fresh_reset flag immediately so it doesn't leak
|
||
# onto subsequent messages in the same session (issue #6508).
|
||
if getattr(session_entry, "is_fresh_reset", False):
|
||
session_entry.is_fresh_reset = False
|
||
if _is_new_session:
|
||
await self.hooks.emit("session:start", {
|
||
"platform": source.platform.value if source.platform else "",
|
||
"user_id": source.user_id,
|
||
"session_id": session_entry.session_id,
|
||
"session_key": session_key,
|
||
})
|
||
return _was_auto_reset, _is_new_session
|
||
|
||
async def _hmwa_deliver_auto_reset_notice(self, session_entry, source, turn_sidecar_notes):
|
||
"""Stage the auto-reset sidecar note for the agent and notify the user (policy-gated)."""
|
||
from gateway.run import _AUTO_RESET_CONTEXT_NOTES, _auto_reset_reason_text
|
||
reset_reason = getattr(session_entry, 'auto_reset_reason', None) or 'idle'
|
||
context_note = _AUTO_RESET_CONTEXT_NOTES.get(reset_reason, _AUTO_RESET_CONTEXT_NOTES["idle"])
|
||
# Slack/Discord channels/threads are long-lived: point the agent at the specific prior
|
||
# same-channel session so it recalls that context via session_search instead of an
|
||
# unrelated recent session. Deterministic — no extra API/DB calls.
|
||
try:
|
||
continuity_note = build_channel_continuity_note(session_entry, source)
|
||
except Exception:
|
||
continuity_note = None
|
||
if continuity_note:
|
||
context_note = context_note + "\n\n" + continuity_note
|
||
turn_sidecar_notes.append(context_note)
|
||
|
||
# Notify the user about the reset unless notifications are disabled in config, the
|
||
# platform is excluded (e.g. api_server, webhook), or the expired session had no
|
||
# activity.
|
||
try:
|
||
policy = self.session_store.config.get_reset_policy(
|
||
platform=source.platform,
|
||
session_type=getattr(source, 'chat_type', 'dm'),
|
||
)
|
||
platform_name = source.platform.value if source.platform else ""
|
||
had_activity = getattr(session_entry, 'reset_had_activity', False)
|
||
# Suspended and restart-recovery-expired sessions always notify regardless of
|
||
# policy.notify — the user had an active session that was silently replaced, so they
|
||
# need to know they can /resume it. Idle/daily resets respect the policy flag.
|
||
should_notify = reset_reason in {"suspended", "resume_pending_expired"} or (
|
||
policy.notify
|
||
and had_activity
|
||
and platform_name not in policy.notify_exclude_platforms
|
||
)
|
||
if should_notify:
|
||
adapter = self._adapter_for_source(source)
|
||
if adapter:
|
||
reason_text = _auto_reset_reason_text(reset_reason, policy)
|
||
notice = (
|
||
f"◐ Session automatically reset ({reason_text}). "
|
||
f"Conversation history cleared.\n"
|
||
f"Use /resume to browse and restore a previous session.\n"
|
||
f"Adjust reset timing in config.yaml under session_reset."
|
||
)
|
||
try:
|
||
session_info = await asyncio.to_thread(
|
||
self._reset_notice_session_info, source
|
||
)
|
||
if session_info:
|
||
notice = f"{notice}\n\n{session_info}"
|
||
except Exception:
|
||
pass
|
||
await adapter.send(
|
||
source.chat_id, notice,
|
||
metadata=self._thread_metadata_for_source(source),
|
||
)
|
||
except Exception as e:
|
||
logger.debug("Auto-reset notification failed (non-fatal): %s", e)
|
||
|
||
# was_auto_reset is already consumed in the cleanup block above
|
||
# (single source of truth); only the reset reason needs clearing here.
|
||
session_entry.auto_reset_reason = None
|
||
|
||
def _hmwa_auto_load_skills(self, event, _auto, _quick_key, session_key):
|
||
"""Prepend topic/channel-bound skill payload(s) to ``event.text`` on a new session."""
|
||
_skill_names = [_auto] if isinstance(_auto, str) else list(_auto)
|
||
try:
|
||
from agent.skill_commands import _load_skill_payload, _build_skill_message
|
||
_combined_parts: list[str] = []
|
||
_loaded_names: list[str] = []
|
||
for _sname in _skill_names:
|
||
_loaded = _load_skill_payload(_sname, task_id=_quick_key)
|
||
if _loaded:
|
||
_loaded_skill, _skill_dir, _display_name = _loaded
|
||
_note = (
|
||
f'[IMPORTANT: The "{_display_name}" skill is auto-loaded. '
|
||
f"Follow its instructions for this session.]"
|
||
)
|
||
_part = _build_skill_message(_loaded_skill, _skill_dir, _note)
|
||
if _part:
|
||
_combined_parts.append(_part)
|
||
_loaded_names.append(_sname)
|
||
else:
|
||
logger.warning("[Gateway] Auto-skill '%s' not found", _sname)
|
||
if _combined_parts:
|
||
# Append the user's original text after all skill payloads
|
||
_combined_parts.append(event.text)
|
||
event.text = "\n\n".join(_combined_parts)
|
||
logger.info(
|
||
"[Gateway] Auto-loaded skill(s) %s for session %s",
|
||
_loaded_names, session_key,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e)
|
||
|
||
async def _hmwa_acquire_turn_lease(self, _quick_key, run_generation, session_entry, _session_env_tokens):
|
||
# ── Turn lease: session resolution is FINAL here. Serialize [load history → run → flush]
|
||
# per resolved SESSION_ID: another routing key mapped to the same session_id waits for the
|
||
# prior flush instead of loading a stale base and interleaving writes (same-key messages
|
||
# never reach here mid-turn thanks to adapter + runner guards, so the lock is otherwise
|
||
# uncontended). Fail-closed on timeout: never enter the transcript region without a lease;
|
||
# outer dispatch returns a bounded resend notice. Released in _handle_message's finally
|
||
# (_release_turn_lease), granted per (routing key, run generation) so a stale unwind can't
|
||
# release a newer turn's.
|
||
from gateway.run import _float_env
|
||
_lease_registry = getattr(self, "_turn_leases", None)
|
||
if _lease_registry is not None:
|
||
try:
|
||
_lease_token = await _lease_registry.acquire(
|
||
session_entry.session_id,
|
||
owner_key=_quick_key,
|
||
generation=run_generation,
|
||
timeout=_float_env(
|
||
"HERMES_TURN_LEASE_TIMEOUT", DEFAULT_LEASE_WAIT
|
||
),
|
||
)
|
||
except TurnLeaseTimeoutError:
|
||
# The broad session-context cleanup finally starts later in this
|
||
# method. Restore the tokens here before propagating the rejection
|
||
# to outer dispatch, or this early exit leaks task-local identity.
|
||
self._clear_session_env(_session_env_tokens)
|
||
raise
|
||
if _lease_token is not None:
|
||
_lease_state = self._session_state(_quick_key).turn
|
||
_lease_state.lease_token = _lease_token
|
||
_lease_state.lease_generation = run_generation
|
||
|
||
async def _hmwa_hygiene_settings(self, source, session_key):
|
||
"""Resolve model/provider/context-length + hygiene knobs for the pre-agent compression
|
||
safety net (fail-soft: any config/runtime error keeps the defaults)."""
|
||
from gateway.run import _load_gateway_config
|
||
# Read model + compression config. Hygiene threshold is intentionally HIGHER than the
|
||
# agent's own compressor (0.85 vs 0.50): it is a pre-agent safety net for sessions that
|
||
# grew between turns; at 0.50 it compressed prematurely on every turn in long sessions.
|
||
_hyg_model = "anthropic/claude-sonnet-4.6"
|
||
_hyg_threshold_pct = 0.85
|
||
_hyg_compression_enabled = True
|
||
_hyg_hard_msg_limit = 5000
|
||
_hyg_timeout_seconds = 30.0
|
||
_hyg_total_ceiling_seconds = 600.0
|
||
# Max wall-clock the user's TURN is held waiting on hygiene compression before the
|
||
# gateway stops waiting and proceeds on the uncompressed transcript. The compressor keeps
|
||
# running detached; its commit is fenced (revoke_commit_admission) so a stale result can
|
||
# never clobber later turns. Kept well below transport idle-timeouts (Telegram ~30s).
|
||
_hyg_max_turn_hold_seconds = 10.0
|
||
_hyg_failure_cooldown_seconds = 300.0
|
||
_hyg_config_context_length = None
|
||
_hyg_provider = None
|
||
_hyg_base_url = None
|
||
_hyg_api_key = None
|
||
_hyg_configured_model = None
|
||
_hyg_configured_provider = None
|
||
_hyg_configured_base_url = None
|
||
_hyg_data = {}
|
||
try:
|
||
_hyg_data = _load_gateway_config()
|
||
if _hyg_data:
|
||
# Resolve model name (same logic as run_sync)
|
||
_model_cfg = _hyg_data.get("model", {})
|
||
if isinstance(_model_cfg, str):
|
||
_hyg_model = _model_cfg
|
||
elif isinstance(_model_cfg, dict):
|
||
_hyg_model = _model_cfg.get("default") or _model_cfg.get("model") or _hyg_model
|
||
# Read explicit context_length override from model config
|
||
# (same as run_agent.py lines 995-1005)
|
||
_raw_ctx = _model_cfg.get("context_length")
|
||
if _raw_ctx is not None:
|
||
with suppress(TypeError, ValueError):
|
||
_hyg_config_context_length = int(_raw_ctx)
|
||
# Read provider for accurate context detection
|
||
_hyg_provider = _model_cfg.get("provider") or None
|
||
_hyg_base_url = _model_cfg.get("base_url") or None
|
||
|
||
# Only the enabled flag is read; hygiene's threshold is deliberately separate
|
||
# from the agent's compression.threshold (hygiene runs higher).
|
||
_comp_cfg = _hyg_data.get("compression", {})
|
||
if isinstance(_comp_cfg, dict):
|
||
_hyg_compression_enabled = str(
|
||
_comp_cfg.get("enabled", True)
|
||
).lower() in {"true", "1", "yes"}
|
||
_raw_hard_limit = _comp_cfg.get("hygiene_hard_message_limit")
|
||
if _raw_hard_limit is not None:
|
||
try:
|
||
_parsed = int(_raw_hard_limit)
|
||
if _parsed > 0:
|
||
_hyg_hard_msg_limit = _parsed
|
||
except (TypeError, ValueError):
|
||
pass
|
||
_raw_timeout = _comp_cfg.get("hygiene_timeout_seconds")
|
||
if _raw_timeout is not None:
|
||
try:
|
||
_parsed = float(_raw_timeout)
|
||
if _parsed > 0:
|
||
_hyg_timeout_seconds = _parsed
|
||
except (TypeError, ValueError):
|
||
pass
|
||
_raw_ceiling = _comp_cfg.get("hygiene_total_ceiling_seconds")
|
||
if _raw_ceiling is not None:
|
||
try:
|
||
_parsed = float(_raw_ceiling)
|
||
if _parsed > 0:
|
||
_hyg_total_ceiling_seconds = _parsed
|
||
except (TypeError, ValueError):
|
||
pass
|
||
# The ceiling can never be tighter than one idle
|
||
# window, or the extension loop would be dead code.
|
||
_hyg_total_ceiling_seconds = max(
|
||
_hyg_total_ceiling_seconds, _hyg_timeout_seconds,
|
||
)
|
||
_raw_turn_hold = _comp_cfg.get("hygiene_max_turn_hold_seconds")
|
||
if _raw_turn_hold is not None:
|
||
try:
|
||
_parsed = float(_raw_turn_hold)
|
||
if _parsed > 0:
|
||
_hyg_max_turn_hold_seconds = _parsed
|
||
except (TypeError, ValueError):
|
||
pass
|
||
_raw_cooldown = _comp_cfg.get("hygiene_failure_cooldown_seconds")
|
||
if _raw_cooldown is not None:
|
||
try:
|
||
_parsed = float(_raw_cooldown)
|
||
if _parsed >= 0:
|
||
_hyg_failure_cooldown_seconds = _parsed
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
_hyg_configured_model = _hyg_model
|
||
_hyg_configured_provider = _hyg_provider
|
||
_hyg_configured_base_url = _hyg_base_url
|
||
|
||
try:
|
||
_hyg_model, _hyg_runtime = self._resolve_session_agent_runtime(
|
||
source=source,
|
||
session_key=session_key,
|
||
user_config=_hyg_data if isinstance(_hyg_data, dict) else None,
|
||
)
|
||
_hyg_provider = _hyg_runtime.get("provider") or _hyg_provider
|
||
_hyg_base_url = _hyg_runtime.get("base_url") or _hyg_base_url
|
||
_hyg_api_key = _hyg_runtime.get("api_key") or _hyg_api_key
|
||
except Exception:
|
||
pass
|
||
|
||
if _hyg_config_context_length is not None:
|
||
try:
|
||
from hermes_cli.route_identity import should_clear_context_pin_async
|
||
|
||
if await should_clear_context_pin_async(
|
||
_hyg_configured_model,
|
||
_hyg_model,
|
||
_hyg_configured_base_url,
|
||
_hyg_base_url,
|
||
_hyg_configured_provider,
|
||
_hyg_provider,
|
||
):
|
||
_hyg_config_context_length = None
|
||
except Exception:
|
||
_hyg_config_context_length = None
|
||
|
||
# custom_providers per-model context_length fallback (as in run_agent.py); must run
|
||
# after runtime resolution so _hyg_base_url is set.
|
||
if _hyg_config_context_length is None and _hyg_base_url:
|
||
try:
|
||
try:
|
||
from hermes_cli.config import (
|
||
get_compatible_custom_providers as _gw_gcp,
|
||
get_custom_provider_context_length as _gw_gccl,
|
||
)
|
||
_hyg_custom_providers = _gw_gcp(_hyg_data)
|
||
except Exception:
|
||
_hyg_custom_providers = _hyg_data.get("custom_providers")
|
||
if not isinstance(_hyg_custom_providers, list):
|
||
_hyg_custom_providers = []
|
||
_hyg_custom_ctx = _gw_gccl(
|
||
model=_hyg_model,
|
||
base_url=_hyg_base_url,
|
||
custom_providers=_hyg_custom_providers,
|
||
)
|
||
if _hyg_custom_ctx:
|
||
_hyg_config_context_length = int(_hyg_custom_ctx)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return self._HygieneSettings(
|
||
model=_hyg_model,
|
||
threshold_pct=_hyg_threshold_pct,
|
||
compression_enabled=_hyg_compression_enabled,
|
||
hard_msg_limit=_hyg_hard_msg_limit,
|
||
timeout_seconds=_hyg_timeout_seconds,
|
||
total_ceiling_seconds=_hyg_total_ceiling_seconds,
|
||
max_turn_hold_seconds=_hyg_max_turn_hold_seconds,
|
||
failure_cooldown_seconds=_hyg_failure_cooldown_seconds,
|
||
config_context_length=_hyg_config_context_length,
|
||
provider=_hyg_provider,
|
||
base_url=_hyg_base_url,
|
||
api_key=_hyg_api_key,
|
||
data=_hyg_data,
|
||
)
|
||
|
||
async def _hmwa_hygiene_plan(self, hs, history, session_entry, session_key):
|
||
"""Decide whether hygiene compression fires this turn (token/message thresholds, DB-backed
|
||
failure cooldown, in-flight compression). Returns
|
||
``(_needs_compress, _approx_tokens, _msg_count, _warn_token_threshold)``."""
|
||
from agent.model_metadata import (
|
||
estimate_messages_tokens_rough,
|
||
get_model_context_length_async,
|
||
)
|
||
|
||
_hyg_model = hs.model
|
||
_hyg_threshold_pct = hs.threshold_pct
|
||
_hyg_hard_msg_limit = hs.hard_msg_limit
|
||
_hyg_config_context_length = hs.config_context_length
|
||
_hyg_provider = hs.provider
|
||
_hyg_base_url = hs.base_url
|
||
_hyg_api_key = hs.api_key
|
||
|
||
_hyg_context_length = await get_model_context_length_async(
|
||
_hyg_model,
|
||
base_url=_hyg_base_url or "",
|
||
api_key=_hyg_api_key or "",
|
||
config_context_length=_hyg_config_context_length,
|
||
provider=_hyg_provider or "",
|
||
)
|
||
_compress_token_threshold = int(
|
||
_hyg_context_length * _hyg_threshold_pct
|
||
)
|
||
_warn_token_threshold = int(_hyg_context_length * 0.95)
|
||
|
||
_msg_count = len(history)
|
||
|
||
# Prefer actual API-reported tokens from the last turn
|
||
# (stored in session entry) over the rough char-based estimate.
|
||
_stored_tokens = session_entry.last_prompt_tokens
|
||
if _stored_tokens > 0:
|
||
_approx_tokens = _stored_tokens
|
||
_token_source = "actual"
|
||
else:
|
||
_approx_tokens = estimate_messages_tokens_rough(history)
|
||
_token_source = "estimated"
|
||
# Rough estimates run 30-50% high on code/JSON-heavy sessions, which only makes
|
||
# hygiene fire early (safe). Do NOT compensate with a threshold multiplier: 85%
|
||
# * 1.4 = 119% of context kept hygiene from ever firing for ~200K models.
|
||
|
||
# Hard safety valve: force compression at an extreme message count regardless of token
|
||
# estimates, breaking the spiral where API disconnects prevent token data → no
|
||
# compression → more disconnects. Default 5000 sits clear of legitimate 1M+ context
|
||
# sessions (those compress on tokens). Config: compression.hygiene_hard_message_limit.
|
||
_HARD_MSG_LIMIT = _hyg_hard_msg_limit
|
||
_needs_compress = (
|
||
_approx_tokens >= _compress_token_threshold
|
||
or _msg_count >= _HARD_MSG_LIMIT
|
||
)
|
||
|
||
if _needs_compress:
|
||
# Use the persistent DB-backed cooldown (same as the in-conversation compression
|
||
# path in context_compressor.py) so the cooldown survives gateway restarts. The
|
||
# in-memory dict reset on every restart, re-triggering the same failing
|
||
# compression and wedging session storage.
|
||
_session_db = getattr(self, "_session_db", None)
|
||
if _session_db is not None:
|
||
_session_db = getattr(_session_db, "_db", _session_db)
|
||
_getter = getattr(_session_db, "get_compression_failure_cooldown", None)
|
||
if _getter is not None:
|
||
try:
|
||
_cooldown_state = _getter(session_entry.session_id)
|
||
except Exception:
|
||
_cooldown_state = None
|
||
if _cooldown_state and _cooldown_state.get("remaining_seconds", 0) > 0:
|
||
logger.info(
|
||
"Session hygiene: skipping compression for %s; "
|
||
"previous failure cooldown active for %.1fs",
|
||
session_entry.session_id,
|
||
_cooldown_state["remaining_seconds"],
|
||
)
|
||
_needs_compress = False
|
||
|
||
if _needs_compress and await self._session_has_compression_in_flight(
|
||
session_key
|
||
):
|
||
# A prior hygiene/agent compression still holds the durable lock (typically a
|
||
# shielded worker left behind by /stop or /restart). Starting another attempt
|
||
# would wait up to the 600s ceiling behind a commit the fence will refuse, while
|
||
# inbound messages demote to queue.
|
||
logger.info(
|
||
"Session hygiene: skipping compression for %s; "
|
||
"another compression is already in flight",
|
||
session_entry.session_id,
|
||
)
|
||
_needs_compress = False
|
||
|
||
if _needs_compress:
|
||
logger.info(
|
||
"Session hygiene: %s messages, ~%s tokens (%s) — auto-compressing "
|
||
"(threshold: %s%% of %s = %s tokens)",
|
||
_msg_count, f"{_approx_tokens:,}", _token_source,
|
||
int(_hyg_threshold_pct * 100),
|
||
f"{_hyg_context_length:,}",
|
||
f"{_compress_token_threshold:,}",
|
||
)
|
||
return _needs_compress, _approx_tokens, _msg_count, _warn_token_threshold
|
||
|
||
async def _hmwa_hygiene_wait_for_summary(self, attempt, hs, session_entry):
|
||
"""Progress-aware inline wait for the detached hygiene compressor. Returns the compressed
|
||
transcript; raises ``HygieneTurnHoldExceeded`` (turn-hold budget) or
|
||
``asyncio.TimeoutError`` (idle/ceiling/fence cancel) for the caller's handlers."""
|
||
from gateway.run import HygieneTurnHoldExceeded, hygiene_wait_should_extend
|
||
_hyg_commit_fence = attempt.commit_fence
|
||
_hyg_future = attempt.future
|
||
_hyg_wait_started = attempt.wait_started
|
||
_hyg_timeout_seconds = hs.timeout_seconds
|
||
_hyg_total_ceiling_seconds = hs.total_ceiling_seconds
|
||
_hyg_max_turn_hold_seconds = hs.max_turn_hold_seconds
|
||
|
||
# Progress-aware wait: the timeout is an INACTIVITY budget —
|
||
# the worker ticks the fence per streamed token, so a slow but
|
||
# still-generating model extends the deadline. A hard ceiling
|
||
# bounds the total so a trickle stream can't hold the turn.
|
||
while True:
|
||
if _hyg_commit_fence.is_cancelled:
|
||
raise asyncio.TimeoutError
|
||
# Charge the idle budget from the LAST PROGRESS event,
|
||
# not from the start of this wait slice — otherwise
|
||
# silence can approach 2x the configured timeout.
|
||
_hyg_waited = (
|
||
time.monotonic() - _hyg_wait_started
|
||
)
|
||
_slice = min(
|
||
max(
|
||
_hyg_timeout_seconds
|
||
- _hyg_commit_fence.seconds_since_progress(),
|
||
0.005,
|
||
),
|
||
max(
|
||
_hyg_total_ceiling_seconds
|
||
- _hyg_waited,
|
||
0.005,
|
||
),
|
||
)
|
||
# Bounded turn-hold: cap this slice at the remaining
|
||
# turn-hold budget so it is re-checked against
|
||
# _hyg_max_turn_hold_seconds at least that often —
|
||
# otherwise a continuously-streaming worker keeps the
|
||
# slice large and holds the turn until the ceiling.
|
||
_turn_hold_remaining = (
|
||
_hyg_max_turn_hold_seconds
|
||
- (time.monotonic() - _hyg_wait_started)
|
||
)
|
||
if _turn_hold_remaining <= 0:
|
||
# Budget exhausted: force an immediate timeout so
|
||
# the abandonment path below runs.
|
||
_slice = 0.005
|
||
else:
|
||
_slice = min(
|
||
_slice,
|
||
max(_turn_hold_remaining, 0.005),
|
||
)
|
||
# Re-check the fence on a short poll so a
|
||
# /stop or /restart cancel is not stuck
|
||
# behind a full idle window (#96953).
|
||
_idle_left = max(
|
||
_hyg_timeout_seconds
|
||
- _hyg_commit_fence.seconds_since_progress(),
|
||
0.005,
|
||
)
|
||
_slice = min(_slice, 0.25)
|
||
try:
|
||
_compressed, _ = await asyncio.wait_for(
|
||
asyncio.shield(_hyg_future),
|
||
timeout=_slice,
|
||
)
|
||
break
|
||
except asyncio.TimeoutError:
|
||
if _hyg_commit_fence.is_cancelled:
|
||
raise
|
||
_hyg_waited = time.monotonic() - _hyg_wait_started
|
||
_idle = _hyg_commit_fence.seconds_since_progress()
|
||
# Bounded turn-hold: never hold the user's TURN past
|
||
# _hyg_max_turn_hold_seconds even if the summary
|
||
# model is still streaming; fall through to the
|
||
# timeout path, which revokes commit admission and
|
||
# proceeds on the uncompressed transcript, so the
|
||
# wire never trips a transport idle-timeout.
|
||
if (
|
||
_hyg_waited
|
||
>= _hyg_max_turn_hold_seconds
|
||
):
|
||
logger.info(
|
||
"Session hygiene compression for "
|
||
"session %s exceeded the turn-hold "
|
||
"budget (%.1fs >= %.1fs) — "
|
||
"abandoning inline wait, proceeding "
|
||
"without compression this turn",
|
||
session_entry.session_id,
|
||
_hyg_waited,
|
||
_hyg_max_turn_hold_seconds,
|
||
)
|
||
raise HygieneTurnHoldExceeded(
|
||
f"turn-hold budget {_hyg_max_turn_hold_seconds:.1f}s "
|
||
f"elapsed after {_hyg_waited:.1f}s"
|
||
)
|
||
if hygiene_wait_should_extend(
|
||
idle=_idle,
|
||
timeout=_hyg_timeout_seconds,
|
||
waited=_hyg_waited,
|
||
ceiling=_hyg_total_ceiling_seconds,
|
||
fence_cancelled=_hyg_commit_fence.is_cancelled,
|
||
):
|
||
if _slice >= _idle_left - 1e-9:
|
||
logger.info(
|
||
"Session hygiene compression for "
|
||
"session %s still streaming after "
|
||
"%.0fs (last progress %.1fs ago) — "
|
||
"extending wait (ceiling %.0fs)",
|
||
session_entry.session_id,
|
||
_hyg_waited, _idle,
|
||
_hyg_total_ceiling_seconds,
|
||
)
|
||
continue
|
||
raise
|
||
return _compressed
|
||
|
||
async def _hmwa_hygiene_on_turn_hold(self, attempt, hs, session_entry, session_key, source):
|
||
"""``except HygieneTurnHoldExceeded`` body: keep or cancel the worker's commit admission,
|
||
notify the user, and re-raise; returns the compressed transcript only when the worker
|
||
was already committing."""
|
||
from gateway.run import (
|
||
_HYGIENE_TURNHOLD_RETRY_SECONDS,
|
||
_record_hygiene_cooldown,
|
||
_reset_hygiene_failure_streak,
|
||
_stamp_hygiene_compression_provenance,
|
||
)
|
||
_hyg_agent = attempt.agent
|
||
_hyg_meta = attempt.meta
|
||
_hyg_commit_fence = attempt.commit_fence
|
||
_hyg_future = attempt.future
|
||
_hyg_wait_started = attempt.wait_started
|
||
_hyg_max_turn_hold_seconds = hs.max_turn_hold_seconds
|
||
|
||
# Turn-hold expiry is an availability boundary, not a
|
||
# failure: the compressor is healthy and still streaming; we
|
||
# just can't hold the turn longer. Share the safe mechanics
|
||
# (fence, release, defer, proceed uncompressed) with
|
||
# distinct provenance / user message and NO failure-cooldown
|
||
# increment. Decouple the TURN from the COMPRESSION: when
|
||
# the worker's commit is watermark-fenced (rows appended
|
||
# after compression start, this turn included, survive its
|
||
# late commit as cloned concurrent tail) the attempt KEEPS
|
||
# commit admission — the turn proceeds uncompressed NOW and
|
||
# the summary is adopted at the worker's own fenced commit
|
||
# (archive_and_compact / rotation publish); always
|
||
# cancelling burned every attempt for thinking summary
|
||
# models whose reasoning prefix alone exceeds the hold. If
|
||
# NOT watermark-fenced (no session_db, capture failed,
|
||
# legacy lock API) a late commit could clobber newer turns,
|
||
# so cancel.
|
||
_hyg_keep_admission = bool(
|
||
getattr(
|
||
_hyg_commit_fence,
|
||
"commit_watermark_fenced",
|
||
False,
|
||
)
|
||
) and not _hyg_commit_fence.is_cancelled
|
||
if _hyg_keep_admission:
|
||
self._defer_agent_cleanup_until_future_done(
|
||
_hyg_future,
|
||
_hyg_agent,
|
||
context="session hygiene turn-hold",
|
||
)
|
||
attempt.cleanup_deferred = True
|
||
# NO retry-after here: the attempt is still running
|
||
# toward a real commit, and the flat 60s retry-after
|
||
# would also block the agent-side preflight compressor
|
||
# (same-session cooldown). Re-attempt spacing comes from
|
||
# the durable compression lock instead: the next turn's
|
||
# hygiene pre-check skips while this worker's lease is
|
||
# held (_session_has_compression_in_flight). The
|
||
# done-callback below records the flat retry-after ONLY
|
||
# if the worker ends without committing anything.
|
||
_hyg_deferred_sid = session_entry.session_id
|
||
_hyg_deferred_key = session_key
|
||
_hyg_deferred_agent = _hyg_agent
|
||
|
||
def _hyg_adopt_or_space_retry(
|
||
_fut,
|
||
_gw=self,
|
||
_sid=_hyg_deferred_sid,
|
||
_skey=_hyg_deferred_key,
|
||
_agent=_hyg_deferred_agent,
|
||
):
|
||
try:
|
||
_exc = _fut.exception()
|
||
except (
|
||
asyncio.CancelledError,
|
||
Exception,
|
||
):
|
||
_exc = None
|
||
_committed = False
|
||
else:
|
||
_committed = _exc is None and (
|
||
bool(
|
||
getattr(
|
||
_agent,
|
||
"_last_compaction_in_place",
|
||
False,
|
||
)
|
||
)
|
||
or getattr(
|
||
_agent, "session_id", _sid
|
||
)
|
||
!= _sid
|
||
)
|
||
if _committed:
|
||
logger.info(
|
||
"Session hygiene compression for "
|
||
"session %s finished after the "
|
||
"turn-hold was released — summary "
|
||
"adopted at the watermark-fenced "
|
||
"commit boundary (#97963)",
|
||
_sid,
|
||
)
|
||
try:
|
||
_reset_hygiene_failure_streak(
|
||
_gw, _skey
|
||
)
|
||
except Exception as _rs_err:
|
||
logger.debug(
|
||
"hygiene streak reset after "
|
||
"deferred adoption failed: %s",
|
||
_rs_err,
|
||
)
|
||
else:
|
||
# Nothing to adopt (summary failed, fence
|
||
# refused the commit, or the attempt was
|
||
# superseded). Record flat spacing so sustained
|
||
# traffic does not spawn and abandon a fresh
|
||
# compressor every turn; non-escalating — the
|
||
# failure streak must not advance for a deferral.
|
||
_record_hygiene_cooldown(
|
||
_gw, _sid,
|
||
_HYGIENE_TURNHOLD_RETRY_SECONDS,
|
||
"hygiene compression deferred: "
|
||
"turn-hold budget expired and the "
|
||
"detached attempt did not commit",
|
||
)
|
||
|
||
_hyg_future.add_done_callback(
|
||
_hyg_adopt_or_space_retry
|
||
)
|
||
from agent.session_activity import (
|
||
ActivityProvenance,
|
||
)
|
||
_stamp_hygiene_compression_provenance(
|
||
_hyg_agent,
|
||
"session hygiene compression turn-hold",
|
||
ActivityProvenance.AGENT_COMPRESSION_TURNHOLD,
|
||
"hygiene compression turn-hold "
|
||
"activity stamp failed",
|
||
)
|
||
logger.info(
|
||
"Session hygiene compression for session %s "
|
||
"exceeded turn-hold budget (%.1fs); "
|
||
"proceeding without compression this turn — "
|
||
"the watermark-fenced worker keeps its "
|
||
"commit admission and the summary will be "
|
||
"adopted when it finishes",
|
||
session_entry.session_id,
|
||
time.monotonic() - _hyg_wait_started,
|
||
)
|
||
_turnhold_msg = t(
|
||
"gateway.compress.turnhold_deferred"
|
||
)
|
||
try:
|
||
_adapter = self._adapter_for_source(source)
|
||
if _adapter and source.chat_id:
|
||
await _adapter.send(
|
||
source.chat_id,
|
||
_turnhold_msg,
|
||
metadata=_hyg_meta,
|
||
)
|
||
except Exception as _werr:
|
||
logger.warning(
|
||
"Failed to deliver compression-turnhold "
|
||
"notice to user: %s",
|
||
_werr,
|
||
)
|
||
raise
|
||
_cancelled = None
|
||
while _cancelled is None:
|
||
if _hyg_commit_fence.commit_in_flight:
|
||
_cancelled = False
|
||
break
|
||
_cancelled = (
|
||
_hyg_commit_fence.try_cancel_before_commit()
|
||
)
|
||
if _cancelled is None:
|
||
await asyncio.sleep(0.025)
|
||
if not _cancelled:
|
||
# NOTE: bounded overshoot by design: the turn can be held
|
||
# past _hyg_max_turn_hold_seconds by up to the commit
|
||
# duration. Aborting mid-commit would corrupt the
|
||
# message-store transaction — the overshoot is the
|
||
# cheaper failure mode. Do NOT "fix" this into a
|
||
# mid-commit cancellation.
|
||
_compressed, _ = await _hyg_future
|
||
else:
|
||
_hyg_commit_fence.release_cancelled_compression_lock()
|
||
self._defer_agent_cleanup_until_future_done(
|
||
_hyg_future,
|
||
_hyg_agent,
|
||
context="session hygiene turn-hold",
|
||
)
|
||
attempt.cleanup_deferred = True
|
||
# Short, NON-escalating retry-after. Without it every
|
||
# turn re-spawns a compressor, holds it for the turn-hold
|
||
# budget and cancels it — token burn that never commits.
|
||
# Deliberately NOT _hygiene_cooldown_for_failure: the
|
||
# compressor is healthy, so the failure streak must not
|
||
# advance; only flat retry spacing is recorded.
|
||
_record_hygiene_cooldown(
|
||
self, session_entry.session_id,
|
||
_HYGIENE_TURNHOLD_RETRY_SECONDS,
|
||
"hygiene compression deferred: "
|
||
"turn-hold budget expired while the "
|
||
"summary was still streaming",
|
||
)
|
||
from agent.session_activity import (
|
||
ActivityProvenance,
|
||
)
|
||
_stamp_hygiene_compression_provenance(
|
||
_hyg_agent,
|
||
"session hygiene compression turn-hold",
|
||
ActivityProvenance.AGENT_COMPRESSION_TURNHOLD,
|
||
"hygiene compression turn-hold "
|
||
"activity stamp failed",
|
||
)
|
||
logger.info(
|
||
"Session hygiene compression for session %s "
|
||
"exceeded turn-hold budget (%.1fs); "
|
||
"proceeding without compression this turn",
|
||
session_entry.session_id,
|
||
time.monotonic() - _hyg_wait_started,
|
||
)
|
||
_turnhold_msg = t(
|
||
"gateway.compress.turnhold_deferred"
|
||
)
|
||
try:
|
||
_adapter = self._adapter_for_source(source)
|
||
if _adapter and source.chat_id:
|
||
await _adapter.send(
|
||
source.chat_id,
|
||
_turnhold_msg,
|
||
metadata=_hyg_meta,
|
||
)
|
||
except Exception as _werr:
|
||
logger.warning(
|
||
"Failed to deliver compression-turnhold "
|
||
"notice to user: %s",
|
||
_werr,
|
||
)
|
||
raise
|
||
return _compressed
|
||
|
||
async def _hmwa_hygiene_on_timeout(self, attempt, hs, session_entry, session_key, source):
|
||
"""``except asyncio.TimeoutError`` body: cancel at the commit fence, record the failure
|
||
cooldown, warn the user, and re-raise; returns the compressed transcript only when the
|
||
worker crossed the commit boundary first."""
|
||
from gateway.run import (
|
||
_hygiene_compression_timeout_message,
|
||
_hygiene_cooldown_for_failure,
|
||
_record_hygiene_cooldown,
|
||
_stamp_hygiene_compression_provenance,
|
||
)
|
||
_hyg_agent = attempt.agent
|
||
_hyg_meta = attempt.meta
|
||
_hyg_commit_fence = attempt.commit_fence
|
||
_hyg_future = attempt.future
|
||
_hyg_wait_started = attempt.wait_started
|
||
_hyg_timeout_seconds = hs.timeout_seconds
|
||
_hyg_total_ceiling_seconds = hs.total_ceiling_seconds
|
||
_hyg_failure_cooldown_seconds = hs.failure_cooldown_seconds
|
||
|
||
_hyg_waited = time.monotonic() - _hyg_wait_started
|
||
_hyg_total_exhausted = (
|
||
_hyg_waited >= _hyg_total_ceiling_seconds
|
||
or _hyg_commit_fence.deadline_exceeded
|
||
)
|
||
if _hyg_total_exhausted:
|
||
# The worker cooperatively checks this deadline between
|
||
# digest calls. Keep its lease until it exits so an
|
||
# unchanged session cannot overlap a retry.
|
||
_hyg_commit_fence.retain_compression_lock_until_worker_done()
|
||
# Capture fence state BEFORE try_cancel — that call itself
|
||
# sets is_cancelled, which would mis-label a genuine idle
|
||
# timeout as a fence cancel.
|
||
_hyg_fence_cancelled = (
|
||
_hyg_commit_fence.is_cancelled
|
||
)
|
||
_cancelled = None
|
||
while _cancelled is None:
|
||
# #76354 F1: a hung commit retains the fence lock; the
|
||
# lock-free phase marker keeps this loop from spinning
|
||
# forever while the commit blocks.
|
||
if _hyg_commit_fence.commit_in_flight:
|
||
_cancelled = False
|
||
break
|
||
_cancelled = (
|
||
_hyg_commit_fence.try_cancel_before_commit()
|
||
)
|
||
if _cancelled is None:
|
||
# Round-2 #5: transient lock-setup windows ride
|
||
# write patience for seconds; 25ms keeps sub-tick
|
||
# latency without 1kHz spin.
|
||
await asyncio.sleep(0.025)
|
||
if not _cancelled:
|
||
# The worker crossed the commit boundary just before the
|
||
# timeout; the fence poll waited for it to finish, so
|
||
# consume the result instead of treating a successful
|
||
# compaction as a timeout.
|
||
_compressed, _ = await _hyg_future
|
||
else:
|
||
# Release an inactivity-timed-out worker's holder-
|
||
# qualified lease promptly. Total-ceiling attempts
|
||
# retained it above, so this is a no-op for them.
|
||
_hyg_commit_fence.release_cancelled_compression_lock()
|
||
self._defer_agent_cleanup_until_future_done(
|
||
_hyg_future,
|
||
_hyg_agent,
|
||
context="session hygiene timeout",
|
||
)
|
||
attempt.cleanup_deferred = True
|
||
_hyg_timeout_error = (
|
||
"session hygiene compression "
|
||
"cancelled at commit fence"
|
||
if _hyg_fence_cancelled
|
||
else (
|
||
"session hygiene compression "
|
||
"timed out with no output from "
|
||
"the summary model"
|
||
)
|
||
)
|
||
if _hyg_failure_cooldown_seconds >= 0:
|
||
_hyg_cooldown = await asyncio.to_thread(
|
||
_hygiene_cooldown_for_failure,
|
||
self,
|
||
session_key,
|
||
_hyg_failure_cooldown_seconds,
|
||
)
|
||
_timeout_reason = (
|
||
_hyg_timeout_error
|
||
if _hyg_fence_cancelled
|
||
else (
|
||
"session hygiene compression total "
|
||
"ceiling exhausted"
|
||
if _hyg_total_exhausted
|
||
else "session hygiene compression "
|
||
"timed out with no output from the "
|
||
"summary model"
|
||
)
|
||
)
|
||
_record_hygiene_cooldown(
|
||
self, session_entry.session_id,
|
||
_hyg_cooldown,
|
||
_timeout_reason,
|
||
)
|
||
from agent.session_activity import (
|
||
ActivityProvenance,
|
||
)
|
||
_stamp_hygiene_compression_provenance(
|
||
_hyg_agent,
|
||
(
|
||
"session hygiene compression "
|
||
"cancelled at commit fence"
|
||
if _hyg_fence_cancelled
|
||
else "session hygiene compression timed out"
|
||
),
|
||
ActivityProvenance.AGENT_COMPRESSION_TIMEOUT,
|
||
"hygiene compression timeout "
|
||
"activity stamp failed",
|
||
)
|
||
if _hyg_fence_cancelled:
|
||
logger.warning(
|
||
"Session hygiene compression for "
|
||
"session %s was cancelled at the "
|
||
"commit fence; continuing without "
|
||
"compression",
|
||
session_entry.session_id,
|
||
)
|
||
else:
|
||
_hyg_elapsed = (
|
||
time.monotonic() - _hyg_wait_started
|
||
)
|
||
if _hyg_total_exhausted:
|
||
logger.warning(
|
||
"Session hygiene compression for session %s "
|
||
"reached its total ceiling after %.1fs "
|
||
"(progress observed=%s); continuing without "
|
||
"compression",
|
||
session_entry.session_id,
|
||
_hyg_elapsed,
|
||
_hyg_commit_fence.progress_observed,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"Session hygiene compression for session %s "
|
||
"made no progress for %.1fs (total wait "
|
||
"%.1fs, ceiling %.1fs); continuing without "
|
||
"compression",
|
||
session_entry.session_id,
|
||
_hyg_commit_fence.seconds_since_progress(),
|
||
_hyg_elapsed,
|
||
_hyg_total_ceiling_seconds,
|
||
)
|
||
_timeout_msg = (
|
||
_hygiene_compression_timeout_message(
|
||
total_exhausted=_hyg_total_exhausted,
|
||
elapsed=_hyg_elapsed,
|
||
idle_timeout=_hyg_timeout_seconds,
|
||
progress_observed=(
|
||
_hyg_commit_fence.progress_observed
|
||
),
|
||
)
|
||
)
|
||
try:
|
||
_adapter = self._adapter_for_source(source)
|
||
if _adapter and source.chat_id:
|
||
await _adapter.send(
|
||
source.chat_id,
|
||
_timeout_msg,
|
||
metadata=_hyg_meta,
|
||
)
|
||
except Exception as _werr:
|
||
logger.warning(
|
||
"Failed to deliver compression-timeout "
|
||
"warning to user: %s",
|
||
_werr,
|
||
)
|
||
raise
|
||
return _compressed
|
||
|
||
def _hmwa_hygiene_on_unwind(self, attempt, hs, session_entry, session_key):
|
||
"""``except BaseException`` body (caller re-raises): revoke commit admission and record a
|
||
cooldown so the next turn does not immediately re-arm hygiene."""
|
||
from gateway.run import _hygiene_cooldown_for_failure, _record_hygiene_cooldown
|
||
_hyg_agent = attempt.agent
|
||
_hyg_commit_fence = attempt.commit_fence
|
||
_hyg_future = attempt.future
|
||
_hyg_failure_cooldown_seconds = hs.failure_cooldown_seconds
|
||
|
||
# #76354 F2: non-timeout unwind while the detached hygiene
|
||
# worker may still run — KeyboardInterrupt, task
|
||
# cancellation, or any unexpected error. Revoke commit
|
||
# admission (and release the worker's durable lease) BEFORE
|
||
# the host unwinds so the worker can never commit later.
|
||
_hyg_commit_fence.revoke_commit_admission()
|
||
if not attempt.cleanup_deferred:
|
||
self._defer_agent_cleanup_until_future_done(
|
||
_hyg_future,
|
||
_hyg_agent,
|
||
context="session hygiene unwind",
|
||
)
|
||
attempt.cleanup_deferred = True
|
||
# restart drain / task cancel must record a cooldown, or the
|
||
# next turn immediately re-arms hygiene and waits up to 600s
|
||
# behind a fence that would refuse the commit again.
|
||
if _hyg_failure_cooldown_seconds >= 0:
|
||
try:
|
||
_hyg_cooldown = _hygiene_cooldown_for_failure(
|
||
self,
|
||
session_key,
|
||
_hyg_failure_cooldown_seconds,
|
||
)
|
||
_record_hygiene_cooldown(
|
||
self, session_entry.session_id,
|
||
_hyg_cooldown,
|
||
"session hygiene compression "
|
||
"cancelled at commit fence",
|
||
)
|
||
except Exception as _cd_err:
|
||
logger.debug(
|
||
"hygiene unwind cooldown "
|
||
"record failed: %s",
|
||
_cd_err,
|
||
)
|
||
|
||
async def _hmwa_hygiene_apply_result(
|
||
self, attempt, hs, _compressed, history, *,
|
||
_approx_tokens, _msg_count, _warn_token_threshold,
|
||
session_entry, session_key, source, _quick_key, run_generation,
|
||
):
|
||
"""Adopt a finished hygiene compression (rotation / in-place / refused), rebind the session
|
||
+ turn lease, record streak/cooldown, and warn the user on abort. Publishes the
|
||
(possibly replaced) transcript on ``attempt.history``."""
|
||
from gateway.run import (
|
||
_hygiene_cooldown_for_failure,
|
||
_record_hygiene_cooldown,
|
||
_reset_hygiene_failure_streak,
|
||
_stamp_hygiene_compression_provenance,
|
||
hygiene_compaction_recovered,
|
||
)
|
||
from agent.model_metadata import estimate_messages_tokens_rough
|
||
|
||
_hyg_agent = attempt.agent
|
||
_hyg_meta = attempt.meta
|
||
_hyg_commit_fence = attempt.commit_fence
|
||
_hyg_failure_cooldown_seconds = hs.failure_cooldown_seconds
|
||
|
||
# _compress_context ends the old session and creates a new
|
||
# session_id. Write compressed messages into the NEW session so
|
||
# the old transcript stays intact and searchable.
|
||
_hyg_new_sid = _hyg_agent.session_id
|
||
_hyg_rotated = _hyg_new_sid != session_entry.session_id
|
||
_hyg_in_place = bool(
|
||
getattr(_hyg_agent, "_last_compaction_in_place", False)
|
||
)
|
||
# Anti-growth guard: refuse a compression that did not shrink
|
||
# the transcript (observed: 427K -> 598K). Compare like-for-like
|
||
# rough estimates.
|
||
_hyg_in_toks = estimate_messages_tokens_rough(history)
|
||
_hyg_out_toks = estimate_messages_tokens_rough(_compressed)
|
||
if _hyg_rotated and _hyg_out_toks > _hyg_in_toks:
|
||
logger.warning(
|
||
"Gateway hygiene compression for session %s "
|
||
"would grow transcript (~%s -> ~%s tokens); "
|
||
"keeping the original transcript unchanged",
|
||
session_entry.session_id,
|
||
f"{_hyg_in_toks:,}",
|
||
f"{_hyg_out_toks:,}",
|
||
)
|
||
_hyg_rotated = False
|
||
_compressed = history
|
||
# Rewrite the transcript only when rotation produced a NEW
|
||
# session id. In-place compaction needs none:
|
||
# archive_and_compact() already soft-archived the previous
|
||
# active rows and inserted the compacted set, and
|
||
# rewrite_transcript() would run
|
||
# replace_messages(active_only=False) and DELETE the archived
|
||
# turns. Likewise a summary with neither rotation nor a
|
||
# completed archive_and_compact() (unchanged session_id) signals
|
||
# FAILURE; an unconditional rewrite would replace the originals
|
||
# with only the summary (permanent data loss).
|
||
# Write-before-repoint (mirrors manual /compress): if
|
||
# session_entry were repointed to the child SID and
|
||
# rewrite_transcript then failed (lock/ENOSPC), the live entry
|
||
# would reference an empty session — the conversation silently
|
||
# vanishes. Persist the child transcript first, then rebind.
|
||
if _hyg_rotated:
|
||
if not await self.async_session_store.rewrite_transcript(
|
||
_hyg_new_sid, _compressed
|
||
):
|
||
logger.error(
|
||
"Session hygiene: failed to persist "
|
||
"compressed transcript for rotated "
|
||
"session %s → %s; keeping the live "
|
||
"entry on the original session so the "
|
||
"conversation is not dropped",
|
||
session_entry.session_id,
|
||
_hyg_new_sid,
|
||
)
|
||
# Fail closed: treat like no rotation.
|
||
_hyg_rotated = False
|
||
_hyg_in_place = False
|
||
else:
|
||
session_entry.session_id = _hyg_new_sid
|
||
# The held turn lease follows the rotation so an alias
|
||
# key resolving the fresh child still serializes against
|
||
# this turn.
|
||
self._rebind_turn_lease(
|
||
_quick_key, run_generation, _hyg_new_sid
|
||
)
|
||
await self.async_session_store._save()
|
||
await asyncio.to_thread(
|
||
self._sync_telegram_topic_binding,
|
||
source, session_entry,
|
||
reason="hygiene-compression",
|
||
)
|
||
|
||
if _hyg_rotated:
|
||
# Reset stored token count — transcript rewritten
|
||
session_entry.last_prompt_tokens = 0
|
||
attempt.history = _compressed
|
||
_new_count = len(_compressed)
|
||
_new_tokens = estimate_messages_tokens_rough(
|
||
_compressed
|
||
)
|
||
elif _hyg_in_place:
|
||
# archive_and_compact() already persisted the
|
||
# compacted transcript inside _compress_context.
|
||
# Reset counts to match the new active set.
|
||
session_entry.last_prompt_tokens = 0
|
||
attempt.history = _compressed
|
||
_new_count = len(_compressed)
|
||
_new_tokens = estimate_messages_tokens_rough(
|
||
_compressed
|
||
)
|
||
else:
|
||
# No rewrite happened — the transcript is unchanged, so the
|
||
# post-compression counts equal the pre-compression ones.
|
||
_new_count = _msg_count
|
||
_new_tokens = _approx_tokens
|
||
logger.warning(
|
||
"Gateway hygiene compression for session %s "
|
||
"did not rotate or compact in place "
|
||
"(no session_db on the hygiene agent) — "
|
||
"preserving the original transcript instead "
|
||
"of overwriting it with the summary (#21301).",
|
||
session_entry.session_id,
|
||
)
|
||
|
||
logger.info(
|
||
"Session hygiene: compressed %s → %s msgs, "
|
||
"~%s → ~%s tokens",
|
||
_msg_count, _new_count,
|
||
f"{_approx_tokens:,}", f"{_new_tokens:,}",
|
||
)
|
||
|
||
if _new_tokens >= _warn_token_threshold:
|
||
logger.warning(
|
||
"Session hygiene: still ~%s tokens after "
|
||
"compression",
|
||
f"{_new_tokens:,}",
|
||
)
|
||
|
||
# Summary failure aborts the compressor entirely (messages
|
||
# unchanged, nothing dropped). Warn the gateway user visibly
|
||
# — agent.log is invisible on TG/Discord/etc. — so they know
|
||
# the chat is "frozen" at this size and can /compress to
|
||
# retry or /reset to start fresh.
|
||
_comp = getattr(_hyg_agent, "context_compressor", None)
|
||
_hyg_aborted = _comp is not None and getattr(
|
||
_comp, "_last_compress_aborted", False
|
||
)
|
||
# Fence-cancelled _compress_context returns the original
|
||
# transcript with _last_compress_aborted still False
|
||
# (failure_class=commit_fence_cancelled, chunk_count=0). Treat
|
||
# that no-op as an abort so hygiene records a cooldown instead
|
||
# of retrying into the 600s wait. A successful rotate/in-place
|
||
# commit is not an abort even if a later invalidation flipped
|
||
# the fence.
|
||
_hyg_fence_cancelled = bool(
|
||
_hyg_commit_fence.is_cancelled
|
||
and not _hyg_rotated
|
||
and not _hyg_in_place
|
||
)
|
||
if _hyg_fence_cancelled:
|
||
_hyg_aborted = True
|
||
if not _hyg_aborted:
|
||
# Recovery decision lives in the unit-tested predicate: the
|
||
# degenerate "neither rotated nor compacted in place" path
|
||
# sets both flags False and reuses the pre-compression
|
||
# counts, so a numbers-only check would read a no-op as
|
||
# success and clear the streak.
|
||
if hygiene_compaction_recovered(
|
||
aborted=_hyg_aborted,
|
||
rotated=_hyg_rotated,
|
||
in_place=_hyg_in_place,
|
||
msg_count=_msg_count,
|
||
new_count=_new_count,
|
||
approx_tokens=_approx_tokens,
|
||
new_tokens=_new_tokens,
|
||
):
|
||
await asyncio.to_thread(
|
||
_reset_hygiene_failure_streak,
|
||
self,
|
||
session_key,
|
||
)
|
||
if _hyg_aborted:
|
||
if _hyg_failure_cooldown_seconds >= 0:
|
||
_hyg_cooldown = await asyncio.to_thread(
|
||
_hygiene_cooldown_for_failure,
|
||
self,
|
||
session_key,
|
||
_hyg_failure_cooldown_seconds,
|
||
)
|
||
_record_hygiene_cooldown(
|
||
self, session_entry.session_id,
|
||
_hyg_cooldown,
|
||
(
|
||
"session hygiene compression "
|
||
"cancelled at commit fence"
|
||
if _hyg_fence_cancelled
|
||
else getattr(
|
||
_comp, "_last_summary_error", None
|
||
)
|
||
),
|
||
)
|
||
from agent.session_activity import (
|
||
ActivityProvenance,
|
||
)
|
||
_stamp_hygiene_compression_provenance(
|
||
_hyg_agent,
|
||
"session hygiene compression aborted",
|
||
ActivityProvenance.AGENT_COMPRESSION_COOLDOWN,
|
||
"hygiene compression abort "
|
||
"activity stamp failed",
|
||
)
|
||
if not _hyg_fence_cancelled:
|
||
_err = getattr(_comp, "_last_summary_error", None) or "unknown error"
|
||
# Force-redact: provider exception text may contain
|
||
# credentials and this message reaches gateway users.
|
||
from agent.redact import redact_sensitive_text
|
||
_err = redact_sensitive_text(_err, force=True)
|
||
_warn_msg = (
|
||
"⚠️ Context compression aborted "
|
||
f"({_err}). No messages were dropped — "
|
||
"conversation is unchanged. Run /compress "
|
||
"to retry, /reset for a clean session, or "
|
||
"check your auxiliary.compression model "
|
||
"configuration."
|
||
)
|
||
try:
|
||
_adapter = self._adapter_for_source(source)
|
||
if _adapter and source.chat_id:
|
||
await _adapter.send(source.chat_id, _warn_msg, metadata=_hyg_meta)
|
||
except Exception as _werr:
|
||
logger.warning(
|
||
"Failed to deliver compression-failure warning to user: %s",
|
||
_werr,
|
||
)
|
||
# Separately: if the user's CONFIGURED aux model failed and we
|
||
# recovered by falling back to the main model, tell them — a
|
||
# misconfigured auxiliary.compression.model is something only
|
||
# they can fix, and silent recovery would hide it.
|
||
elif _comp is not None and getattr(_comp, "_last_aux_model_failure_model", None):
|
||
_aux_model = getattr(_comp, "_last_aux_model_failure_model", "")
|
||
_aux_err = getattr(_comp, "_last_aux_model_failure_error", None) or "unknown error"
|
||
_aux_msg = (
|
||
f"ℹ️ Configured compression model `{_aux_model}` "
|
||
f"failed ({_aux_err}). Recovered using your main "
|
||
"model — context is intact — but you may want to "
|
||
"check `auxiliary.compression.model` in config.yaml."
|
||
)
|
||
try:
|
||
_adapter = self._adapter_for_source(source)
|
||
if _adapter and source.chat_id:
|
||
await _adapter.send(source.chat_id, _aux_msg, metadata=_hyg_meta)
|
||
except Exception as _werr:
|
||
logger.warning(
|
||
"Failed to deliver aux-model-fallback notice to user: %s",
|
||
_werr,
|
||
)
|
||
|
||
async def _hmwa_run_session_hygiene(
|
||
self, event, source, session_entry, session_key, history, _quick_key, run_generation,
|
||
):
|
||
# Session hygiene: auto-compress pathologically large transcripts before the agent starts so
|
||
# oversized histories don't cause repeated truncation/context failures. Token source: the
|
||
# API's prompt_tokens from the last turn (session_entry.last_prompt_tokens), else a char/4
|
||
# estimate (30-50% high on code-heavy sessions, so hygiene merely fires a bit early).
|
||
from gateway.run import (
|
||
HygieneTurnHoldExceeded,
|
||
_GATEWAY_HYGIENE_PLATFORM,
|
||
_seed_hygiene_system_prompt,
|
||
run_codex_hygiene_compaction,
|
||
)
|
||
if not history or len(history) < 4:
|
||
return history
|
||
|
||
hs = await self._hmwa_hygiene_settings(source, session_key)
|
||
if not hs.compression_enabled:
|
||
return history
|
||
_needs_compress, _approx_tokens, _msg_count, _warn_token_threshold = (
|
||
await self._hmwa_hygiene_plan(hs, history, session_entry, session_key)
|
||
)
|
||
if not _needs_compress:
|
||
return history
|
||
|
||
_hyg_total_ceiling_seconds = hs.total_ceiling_seconds
|
||
_hyg_failure_cooldown_seconds = hs.failure_cooldown_seconds
|
||
_hyg_data = hs.data
|
||
|
||
_hyg_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
|
||
|
||
try:
|
||
from agent.conversation_compression import CompressionCommitFence
|
||
from run_agent import AIAgent
|
||
|
||
_hyg_model, _hyg_runtime = self._resolve_session_agent_runtime(
|
||
source=source,
|
||
session_key=session_key,
|
||
user_config=_hyg_data if isinstance(_hyg_data, dict) else None,
|
||
)
|
||
_hyg_api_mode = str(
|
||
_hyg_runtime.get("api_mode") or ""
|
||
).lower()
|
||
if _hyg_api_mode == "codex_app_server":
|
||
# codex app-server runtime: the real context is the server-side thread,
|
||
# not the transcript mirror. The detached-agent block below would only
|
||
# rewrite the mirror and its finally-eviction would destroy the live
|
||
# thread (next turn starts blank). Use the cached agent's
|
||
# thread/compact/start and KEEP it cached.
|
||
_hyg_codex_auto = "native"
|
||
_hyg_comp_cfg = (
|
||
_hyg_data.get("compression")
|
||
if isinstance(_hyg_data, dict)
|
||
else None
|
||
)
|
||
if isinstance(_hyg_comp_cfg, dict):
|
||
_hyg_codex_auto = str(
|
||
_hyg_comp_cfg.get(
|
||
"codex_app_server_auto", "native"
|
||
)
|
||
or "native"
|
||
)
|
||
_hyg_codex_outcome = await run_codex_hygiene_compaction(
|
||
self,
|
||
session_key,
|
||
session_entry.session_id,
|
||
auto_mode=_hyg_codex_auto,
|
||
history=history,
|
||
approx_tokens=_approx_tokens,
|
||
timeout_seconds=_hyg_total_ceiling_seconds,
|
||
failure_cooldown_seconds=_hyg_failure_cooldown_seconds,
|
||
)
|
||
logger.info(
|
||
"Session hygiene (codex app-server): %s "
|
||
"(session=%s, mode=%s, ~%s tokens)",
|
||
_hyg_codex_outcome,
|
||
session_entry.session_id,
|
||
_hyg_codex_auto,
|
||
f"{_approx_tokens:,}",
|
||
)
|
||
elif _hyg_runtime.get("api_key"):
|
||
# Pass the FULL transcript (tool results included), matching the agent
|
||
# loop: filtering to user/assistant starved the compressor — tool results
|
||
# are the bulk of context and short histories tripped the
|
||
# protect-first/last early-return so nothing compressed.
|
||
_hyg_msgs = [
|
||
m for m in history
|
||
if m.get("role") in {"user", "assistant", "tool"}
|
||
]
|
||
|
||
if len(_hyg_msgs) >= 4:
|
||
try:
|
||
_hyg_session_row = await self._session_db.get_session(
|
||
session_entry.session_id
|
||
)
|
||
except Exception as exc:
|
||
_hyg_session_row = None
|
||
logger.warning(
|
||
"Session hygiene could not restore the system "
|
||
"prompt for session %s: %s. Preserving an empty "
|
||
"prompt so the live turn rebuilds it with its "
|
||
"configured providers.",
|
||
session_entry.session_id,
|
||
exc,
|
||
exc_info=True,
|
||
)
|
||
_hyg_session_db = getattr(self._session_db, "_db", self._session_db)
|
||
# Hygiene is the same lossy rewrite as normal compression: when
|
||
# compression.checkpoint_required is on, load the memory provider so
|
||
# the checkpoint exists before any mutation; otherwise keep the
|
||
# fast path (no provider init, no best-effort hook).
|
||
from hermes_cli.config import load_config as _load_cfg
|
||
from utils import is_truthy_value as _is_truthy
|
||
|
||
_hyg_checkpoint_required = _is_truthy(
|
||
((_load_cfg() or {}).get("compression") or {}).get(
|
||
"checkpoint_required"
|
||
),
|
||
default=False,
|
||
)
|
||
_hyg_agent = AIAgent(
|
||
**_hyg_runtime,
|
||
model=_hyg_model,
|
||
max_iterations=4,
|
||
quiet_mode=True,
|
||
skip_memory=not _hyg_checkpoint_required,
|
||
enabled_toolsets=["memory"],
|
||
session_id=session_entry.session_id,
|
||
session_db=_hyg_session_db,
|
||
)
|
||
_seed_hygiene_system_prompt(
|
||
_hyg_agent,
|
||
_hyg_session_row,
|
||
)
|
||
# If compression must rebuild instead of retaining
|
||
# the cached prompt, make the persisted result
|
||
# deliberately stale for every real gateway surface.
|
||
_hyg_agent.platform = _GATEWAY_HYGIENE_PLATFORM
|
||
attempt = self._HygieneAttempt(agent=_hyg_agent, meta=_hyg_meta, history=history)
|
||
try:
|
||
# Hygiene runs before the turn and owns the session binding, so
|
||
# prefer in-place compaction: archive old rows under the same
|
||
# session id rather than minting a continuation child that must
|
||
# be published back to SessionStore/topic bindings. Without a
|
||
# SessionDB this stays False and the guard below preserves it.
|
||
_hyg_agent.compression_in_place = True
|
||
_bind_hyg_state = getattr(
|
||
getattr(_hyg_agent, "context_compressor", None),
|
||
"bind_session_state",
|
||
None,
|
||
)
|
||
if callable(_bind_hyg_state):
|
||
_bind_hyg_state(
|
||
_hyg_session_db,
|
||
session_entry.session_id,
|
||
)
|
||
# It must never finalize on close() — close()
|
||
# would end the live gateway session row.
|
||
_hyg_agent._end_session_on_close = False
|
||
_hyg_agent._print_fn = lambda *a, **kw: None
|
||
|
||
loop = asyncio.get_running_loop()
|
||
_hyg_commit_fence = CompressionCommitFence(
|
||
total_ceiling_seconds=_hyg_total_ceiling_seconds
|
||
)
|
||
# Default executor (NOT self._get_executor): a fence-cancelled
|
||
# hung summary must never occupy an agent-work slot. But it MUST
|
||
# run in the caller's contextvars: under multiplex_profiles the
|
||
# secret scope / HERMES_HOME live in ContextVars, and an empty
|
||
# Context makes get_secret() fail closed → lossy truncation.
|
||
_hyg_future = loop.run_in_executor(
|
||
None,
|
||
copy_context().run,
|
||
lambda: _hyg_agent._compress_context(
|
||
_hyg_msgs, "",
|
||
approx_tokens=_approx_tokens,
|
||
commit_fence=_hyg_commit_fence,
|
||
),
|
||
)
|
||
attempt.commit_fence = _hyg_commit_fence
|
||
attempt.future = _hyg_future
|
||
attempt.wait_started = time.monotonic()
|
||
try:
|
||
_compressed = await self._hmwa_hygiene_wait_for_summary(
|
||
attempt, hs, session_entry,
|
||
)
|
||
except HygieneTurnHoldExceeded:
|
||
_compressed = await self._hmwa_hygiene_on_turn_hold(
|
||
attempt, hs, session_entry, session_key, source,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
_compressed = await self._hmwa_hygiene_on_timeout(
|
||
attempt, hs, session_entry, session_key, source,
|
||
)
|
||
except BaseException:
|
||
self._hmwa_hygiene_on_unwind(attempt, hs, session_entry, session_key)
|
||
raise
|
||
|
||
await self._hmwa_hygiene_apply_result(
|
||
attempt, hs, _compressed, history,
|
||
_approx_tokens=_approx_tokens,
|
||
_msg_count=_msg_count,
|
||
_warn_token_threshold=_warn_token_threshold,
|
||
session_entry=session_entry,
|
||
session_key=session_key,
|
||
source=source,
|
||
_quick_key=_quick_key,
|
||
run_generation=run_generation,
|
||
)
|
||
finally:
|
||
history = attempt.history
|
||
# Evict the cached agent so the next turn rebuilds its system
|
||
# prompt from current SOUL.md, memory, and skills.
|
||
self._evict_cached_agent(session_key)
|
||
if not attempt.cleanup_deferred:
|
||
await self._cleanup_agent_resources_off_loop(
|
||
_hyg_agent, context="session hygiene"
|
||
)
|
||
except HygieneTurnHoldExceeded:
|
||
# Availability boundary, not a failure — already logged at INFO by the turn-
|
||
# hold handler. Must not hit the generic "auto-compress failed" warning
|
||
# below: that log made thinking-model deployments read as permanently broken.
|
||
pass
|
||
except Exception as e:
|
||
logger.warning(
|
||
"Session hygiene auto-compress failed: %s", e
|
||
)
|
||
return history
|
||
|
||
async def _hmwa_first_contact_notes(self, source, history, turn_sidecar_notes):
|
||
"""First-ever-message onboarding note + one-time 'no home channel' prompt (both only when
|
||
the session has no history)."""
|
||
from gateway.run import _hermes_home, _home_target_env_var, _load_gateway_config
|
||
# First-message onboarding -- only on the very first interaction ever. Delivered on the
|
||
# current user message (sidecar), NOT the ephemeral system prompt: present-on-turn-1/absent-
|
||
# on-turn-2 was a guaranteed system-prompt diff and agent rebuild.
|
||
if not history and not await self.async_session_store.has_any_sessions():
|
||
# Default first-contact note: a brief self-introduction.
|
||
_intro_note = (
|
||
"[System note: This is the user's very first message ever. "
|
||
"Briefly introduce yourself and mention that /help shows available commands. "
|
||
"Keep the introduction concise -- one or two sentences max.]"
|
||
)
|
||
# Opt-in structured profile-build path: when enabled (default "ask") and not yet offered
|
||
# on this install, swap the plain intro for a consent-gated directive that offers to
|
||
# build a user profile and persists confirmed facts via memory(target="user"). Fires at
|
||
# most once (onboarding.seen flag); onboarding.profile_build: off in config.yaml
|
||
# disables it.
|
||
try:
|
||
from agent.onboarding import (
|
||
PROFILE_BUILD_FLAG,
|
||
is_seen,
|
||
mark_seen,
|
||
profile_build_directive,
|
||
profile_build_mode,
|
||
)
|
||
_onb_cfg = _load_gateway_config()
|
||
if (
|
||
profile_build_mode(_onb_cfg) == "ask"
|
||
and not is_seen(_onb_cfg, PROFILE_BUILD_FLAG)
|
||
):
|
||
turn_sidecar_notes.append(profile_build_directive().strip())
|
||
mark_seen(_hermes_home / "config.yaml", PROFILE_BUILD_FLAG)
|
||
else:
|
||
turn_sidecar_notes.append(_intro_note)
|
||
except Exception as _pb_err:
|
||
logger.debug(
|
||
"Profile-build onboarding directive failed, using plain intro: %s",
|
||
_pb_err,
|
||
)
|
||
turn_sidecar_notes.append(_intro_note)
|
||
|
||
# One-time prompt if no home channel is set for this platform
|
||
# Skip for webhooks - they deliver directly to configured targets (github_comment, etc.)
|
||
if not history and source.platform and source.platform != Platform.LOCAL and source.platform != Platform.WEBHOOK:
|
||
platform_name = source.platform.value
|
||
env_key = _home_target_env_var(platform_name)
|
||
# Multiplex: home channel may live only in the profile secret
|
||
# scope / PlatformConfig, not process os.environ.
|
||
home_env = ""
|
||
try:
|
||
from agent.secret_scope import get_secret
|
||
|
||
home_env = (get_secret(env_key) or "").strip() if env_key else ""
|
||
except Exception:
|
||
home_env = ""
|
||
if not home_env:
|
||
home_env = (os.getenv(env_key) or "").strip() if env_key else ""
|
||
# Also honor in-memory / yaml home_channel on this platform.
|
||
try:
|
||
if not home_env and self.config.get_home_channel(source.platform):
|
||
home_env = "set"
|
||
except Exception:
|
||
pass
|
||
# Secondary-profile platforms (e.g. Slack on yolo) may only exist
|
||
# under that profile's loaded config — check after scope install.
|
||
if not home_env:
|
||
try:
|
||
from gateway.config import load_gateway_config as _lgc
|
||
prof = (getattr(source, "profile", None) or "").strip()
|
||
if prof and prof != "default":
|
||
# Already inside profile scope for secondary handlers;
|
||
# re-read live config for home_channel.
|
||
_pcfg = _lgc()
|
||
if _pcfg.get_home_channel(source.platform):
|
||
home_env = "set"
|
||
except Exception:
|
||
pass
|
||
if not home_env:
|
||
# Slack dispatches all Hermes commands through a single
|
||
# parent slash command `/hermes`; bare `/sethome` is not
|
||
# registered and would fail with "app did not respond".
|
||
sethome_cmd = (
|
||
"/hermes sethome"
|
||
if source.platform == Platform.SLACK
|
||
else "/sethome"
|
||
)
|
||
notice = (
|
||
f"📬 No home channel is set for {platform_name.title()}. "
|
||
f"A home channel is where Hermes delivers cron job results "
|
||
f"and cross-platform messages.\n\n"
|
||
f"Type {sethome_cmd} to make this chat your home channel, "
|
||
f"or ignore to skip."
|
||
)
|
||
await self._deliver_platform_notice(source, notice)
|
||
|
||
def _hmwa_apply_message_timestamp(self, event, message_text):
|
||
# Capture the platform event time as message metadata and keep the persisted transcript
|
||
# clean (strip any leading timestamp prefix). This runs regardless of the toggle so storage
|
||
# stays clean and the send-time is preserved. Only the in-context RENDER (the prefix the
|
||
# model sees) is gated behind gateway.message_timestamps.enabled — default OFF.
|
||
from gateway.run import _load_gateway_config, _message_timestamps_enabled
|
||
persist_user_message = None
|
||
persist_user_timestamp = None
|
||
try:
|
||
from hermes_time import get_timezone as _get_evt_tz
|
||
from gateway.message_timestamps import (
|
||
coerce_message_timestamp as _coerce_msg_ts,
|
||
render_user_content_with_timestamp as _render_msg_ts,
|
||
strip_leading_message_timestamps as _strip_msg_ts,
|
||
)
|
||
_evt_tz = _get_evt_tz()
|
||
_evt_ts = getattr(event, "timestamp", None)
|
||
if message_text and isinstance(message_text, str):
|
||
_clean_message_text, _embedded_ts = _strip_msg_ts(
|
||
message_text, tz=_evt_tz)
|
||
persist_user_message = _clean_message_text
|
||
_event_epoch = _coerce_msg_ts(_evt_ts, tz=_evt_tz)
|
||
persist_user_timestamp = (
|
||
_event_epoch if _event_epoch is not None else _embedded_ts
|
||
)
|
||
if _message_timestamps_enabled(_load_gateway_config()):
|
||
message_text = _render_msg_ts(
|
||
_clean_message_text,
|
||
persist_user_timestamp,
|
||
tz=_evt_tz,
|
||
)
|
||
else:
|
||
# Toggle off: model sees the clean message; the timestamp
|
||
# is still stored as metadata for later opt-in.
|
||
message_text = _clean_message_text
|
||
except Exception as _ts_err:
|
||
logger.debug("Message timestamp injection failed (non-fatal): %s", _ts_err)
|
||
return message_text, persist_user_message, persist_user_timestamp
|
||
|
||
async def _hmwa_stop_typing_for_turn(self, event, source):
|
||
"""Stop the typing indicator (never raises). Slack AI status is scoped to a thread/
|
||
workspace, so preserve the routing metadata used by the response delivery path."""
|
||
try:
|
||
_typing_adapter = self._adapter_for_source(source)
|
||
_stop_with_metadata = getattr(
|
||
type(_typing_adapter), "_stop_typing_with_metadata", None
|
||
)
|
||
_stop_typing = getattr(type(_typing_adapter), "stop_typing", None)
|
||
if _typing_adapter and callable(_stop_with_metadata):
|
||
await _typing_adapter._stop_typing_with_metadata(
|
||
source.chat_id,
|
||
self._thread_metadata_for_source(
|
||
source, self._reply_anchor_for_event(event)
|
||
),
|
||
)
|
||
elif _typing_adapter and callable(_stop_typing):
|
||
await _typing_adapter.stop_typing(source.chat_id)
|
||
except Exception:
|
||
pass
|
||
|
||
async def _hmwa_shape_agent_response(
|
||
self, agent_result, source, history, session_entry, session_key,
|
||
_quick_key, run_generation, _run_start_session_id, _platform_name, _msg_start_time,
|
||
):
|
||
"""Turn the raw agent result into the outbound text: sentinel/silence handling, response
|
||
logging, resume-pending clear, empty-response normalization, and identity-guarded
|
||
post-compression session_id propagation. Returns
|
||
``(response, _intentional_silence, agent_messages)``."""
|
||
from gateway.run import (
|
||
_is_gateway_hidden_reasoning_incomplete_turn,
|
||
_normalize_empty_agent_response,
|
||
_sanitize_gateway_final_response,
|
||
_should_clear_resume_pending_after_turn,
|
||
)
|
||
response = agent_result.get("final_response") or ""
|
||
# Hidden-reasoning-only retry exhaustion: the loop's sentinel text ("Codex response
|
||
# remained incomplete after 3 continuation attempts") doubles as final_response, so it
|
||
# would be delivered verbatim into the channel — where peer agents can ingest it as a
|
||
# completed assistant turn.
|
||
if _is_gateway_hidden_reasoning_incomplete_turn(agent_result):
|
||
response = ""
|
||
try:
|
||
from gateway.response_filters import is_intentional_silence_agent_result
|
||
_intentional_silence = is_intentional_silence_agent_result(
|
||
agent_result, response,
|
||
)
|
||
except Exception:
|
||
_intentional_silence = False
|
||
|
||
# Convert the agent's internal "(empty)" sentinel into a user-friendly message.
|
||
# "(empty)" means the model failed to produce visible content after exhausting all
|
||
# retries (nudge, prefill, empty-retry, fallback).
|
||
if response == "(empty)" and not _intentional_silence:
|
||
response = (
|
||
"⚠️ The model returned no response after processing tool "
|
||
"results. This can happen with some models — try again or "
|
||
"rephrase your question."
|
||
)
|
||
agent_messages = agent_result.get("messages", [])
|
||
_response_time = time.time() - _msg_start_time
|
||
_api_calls = agent_result.get("api_calls", 0)
|
||
_resp_len = len(response)
|
||
logger.info(
|
||
"response ready: platform=%s chat=%s time=%.1fs api_calls=%d response=%d chars",
|
||
_platform_name, source.chat_id or "unknown",
|
||
_response_time, _api_calls, _resp_len,
|
||
)
|
||
|
||
# The cross-process cache-coherence re-baseline (_refresh_agent_cache_message_count) is
|
||
# deferred until AFTER the transcript persistence block below: it must include the
|
||
# first-turn `session_meta` marker row and the compression session_id swap.
|
||
|
||
# Successful turn: clear the stuck-loop counter (it only accumulates across CONSECUTIVE
|
||
# restarts where the session never completed) and resume_pending (set by drain-timeout
|
||
# shutdown) so later messages don't get the restart-interruption system note.
|
||
if session_key and _should_clear_resume_pending_after_turn(agent_result):
|
||
await self._clear_restart_failure_count(session_key)
|
||
try:
|
||
await self.async_session_store.clear_resume_pending(session_key)
|
||
except Exception as _e:
|
||
logger.debug(
|
||
"clear_resume_pending failed for %s: %s",
|
||
session_key, _e,
|
||
)
|
||
|
||
# Normalize empty responses: surface errors, partial failures, and
|
||
# the case where agent did work but returned no text. Fix for #18765.
|
||
if not _intentional_silence:
|
||
response = _normalize_empty_agent_response(
|
||
agent_result, response, history_len=len(history),
|
||
)
|
||
response = _sanitize_gateway_final_response(source.platform, response)
|
||
|
||
# Ordering contract: the agent thread already updated the contextvar in
|
||
# conversation_compression.py; propagate to SessionEntry + _save().
|
||
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
|
||
if session_entry.session_id == _run_start_session_id:
|
||
session_entry.session_id = agent_result["session_id"]
|
||
# The held turn lease follows the rotation: the transcript persistence below
|
||
# writes to the NEW id, so the serialization boundary must move with it or an
|
||
# alias key resolving the fresh child could interleave.
|
||
self._rebind_turn_lease(
|
||
_quick_key, run_generation, session_entry.session_id
|
||
)
|
||
await self.async_session_store._save()
|
||
await self.async_session_store._record_gateway_session_peer(
|
||
session_entry.session_id,
|
||
session_key,
|
||
source,
|
||
)
|
||
await asyncio.to_thread(
|
||
self._sync_telegram_topic_binding,
|
||
source, session_entry, reason="agent-result-compression",
|
||
)
|
||
else:
|
||
logger.info(
|
||
"Skipping agent-result session split sync for %s because "
|
||
"the session binding moved from %s to %s before "
|
||
"compression finished",
|
||
session_key or "?",
|
||
_run_start_session_id,
|
||
session_entry.session_id,
|
||
)
|
||
return response, _intentional_silence, agent_messages
|
||
|
||
def _hmwa_prepend_reasoning(self, agent_result, response, source, _intentional_silence):
|
||
# Prepend reasoning if display is enabled (per-platform). Mattermost requires explicit
|
||
# opt-in because this is scratch text, not ordinary final-answer content.
|
||
from gateway.run import _load_gateway_config, _platform_config_key, _resolve_gateway_display_bool
|
||
try:
|
||
_show_reasoning_effective = _resolve_gateway_display_bool(
|
||
_load_gateway_config(),
|
||
_platform_config_key(source.platform),
|
||
"show_reasoning",
|
||
default=bool(getattr(self, "_show_reasoning", False)),
|
||
platform=source.platform,
|
||
require_platform_override_for={Platform.MATTERMOST},
|
||
)
|
||
except Exception:
|
||
_show_reasoning_effective = (
|
||
False
|
||
if source.platform == Platform.MATTERMOST
|
||
else getattr(self, "_show_reasoning", False)
|
||
)
|
||
if _show_reasoning_effective and response and not _intentional_silence:
|
||
last_reasoning = agent_result.get("last_reasoning")
|
||
if last_reasoning:
|
||
from gateway.stream_consumer import escape_code_fences_for_display
|
||
# Collapse long reasoning to keep messages readable
|
||
lines = last_reasoning.strip().splitlines()
|
||
if len(lines) > 15:
|
||
display_reasoning = "\n".join(lines[:15])
|
||
display_reasoning += f"\n_... ({len(lines) - 15} more lines)_"
|
||
else:
|
||
display_reasoning = last_reasoning.strip()
|
||
# Render style is per-platform: Discord defaults to "-# " subtext (native small
|
||
# grey metadata text); other platforms keep the fenced code block.
|
||
try:
|
||
from gateway.display_config import resolve_display_setting
|
||
_reasoning_style = resolve_display_setting(
|
||
_load_gateway_config(),
|
||
_platform_config_key(source.platform),
|
||
"reasoning_style",
|
||
"code",
|
||
)
|
||
except Exception:
|
||
_reasoning_style = "code"
|
||
if _reasoning_style == "subtext":
|
||
_quoted = "\n".join(
|
||
f"-# {ln}" if ln else "-#" for ln in display_reasoning.splitlines()
|
||
)
|
||
response = f"-# 💭 Reasoning\n{_quoted}\n\n{response}"
|
||
elif _reasoning_style == "blockquote":
|
||
_quoted = "\n".join(
|
||
f"> {ln}" if ln else ">" for ln in display_reasoning.splitlines()
|
||
)
|
||
response = f"> 💭 **Reasoning:**\n{_quoted}\n\n{response}"
|
||
else:
|
||
# Escape ``` inside reasoning so inner fences don't
|
||
# break the outer code block used to render it.
|
||
display_reasoning = escape_code_fences_for_display(display_reasoning)
|
||
response = f"💭 **Reasoning:**\n```\n{display_reasoning}\n```\n\n{response}"
|
||
return response
|
||
|
||
def _hmwa_runtime_footer_line(self, agent_result, source, _turn_seconds):
|
||
# Runtime-metadata footer — only on the FINAL message of the turn. Off by default
|
||
# (display.runtime_footer.enabled=false). When streaming already delivered the body, we
|
||
# can't mutate the sent text, so we fire a separate trailing send below.
|
||
from gateway.run import _load_gateway_config, _platform_config_key, _terminal_scope_cwd
|
||
_footer_line = ""
|
||
try:
|
||
from gateway.runtime_footer import build_footer_line as _bfl
|
||
_footer_line = _bfl(
|
||
user_config=_load_gateway_config(),
|
||
platform_key=_platform_config_key(source.platform),
|
||
model=agent_result.get("model"),
|
||
context_tokens=agent_result.get("last_prompt_tokens", 0) or 0,
|
||
context_length=agent_result.get("context_length") or None,
|
||
cwd=_terminal_scope_cwd(""),
|
||
turn_seconds=_turn_seconds,
|
||
)
|
||
except Exception as _footer_err:
|
||
logger.debug("runtime_footer build failed: %s", _footer_err)
|
||
_footer_line = ""
|
||
return _footer_line
|
||
|
||
async def _hmwa_post_turn_hooks(self, hook_ctx, agent_result, response):
|
||
"""agent:end hook, process-watcher scheduling, and watch-notification drain."""
|
||
# Emit agent:end hook
|
||
await self.hooks.emit("agent:end", {
|
||
**hook_ctx,
|
||
"response": (response or "")[:500],
|
||
"model": agent_result.get("model", ""),
|
||
"provider": agent_result.get("provider", ""),
|
||
})
|
||
|
||
# Check for pending process watchers (check_interval on background processes)
|
||
try:
|
||
from tools.process_registry import process_registry
|
||
# Detach the current batch atomically (see crash-recovery drain
|
||
# above): reassign to a fresh list so a watcher appended by a
|
||
# concurrent session during the yield isn't dropped by clear().
|
||
watchers = process_registry.pending_watchers
|
||
process_registry.pending_watchers = []
|
||
for i, watcher in enumerate(watchers):
|
||
asyncio.create_task(self._run_process_watcher(watcher))
|
||
if i % 100 == 99:
|
||
await asyncio.sleep(0)
|
||
except Exception as e:
|
||
logger.error("Process watcher setup error: %s", e)
|
||
|
||
# Drain watch notifications that arrived during the run. The queue also carries process
|
||
# completions (handled by the per-process watcher task above) and async-delegation
|
||
# completions (owned by _async_delegation_watcher, the single consumer for idle and
|
||
# post-turn cases) — inject only watch-type events and leave the rest on the queue.
|
||
try:
|
||
from tools.process_registry import process_registry as _pr
|
||
await self._drain_watch_notifications(_pr.completion_queue)
|
||
except Exception as e:
|
||
logger.debug("Watch queue drain error: %s", e)
|
||
|
||
def _hmwa_classify_turn_failure(self, agent_result, history, session_entry):
|
||
"""Classify a finished turn for transcript persistence. Returns
|
||
``(agent_failed_early, hidden_reasoning_incomplete, is_context_overflow_failure)``."""
|
||
from gateway.run import _is_gateway_hidden_reasoning_incomplete_turn
|
||
# Persist the full agent loop (tool calls, results, reasoning) so sessions resume with
|
||
# full context. IMPORTANT: on context-overflow failures (compression exhausted, generic
|
||
# 400 on large sessions) do NOT persist the user message — it would grow the session and
|
||
# reproduce the failure forever. Transient failures (429, timeout, connection error,
|
||
# 5xx) are different: the session is not oversized and dropping the user turn causes
|
||
# severe context loss on retry, so persist it.
|
||
agent_failed_early = bool(agent_result.get("failed"))
|
||
hidden_reasoning_incomplete = _is_gateway_hidden_reasoning_incomplete_turn(
|
||
agent_result
|
||
)
|
||
_err_str_for_classify = str(agent_result.get("error", "")).lower()
|
||
# Use specific multi-word phrases (not bare "exceed"/"token") to avoid false positives
|
||
# on transient errors such as "rate limit exceeded"; matches run_agent.py's classifier.
|
||
is_context_overflow_failure = agent_failed_early and (
|
||
bool(agent_result.get("compression_exhausted"))
|
||
or any(p in _err_str_for_classify for p in (
|
||
"context length", "context size", "context window",
|
||
"maximum context", "token limit", "too many tokens",
|
||
"reduce the length", "exceeds the limit",
|
||
"request entity too large", "prompt is too long",
|
||
"payload too large", "input is too long",
|
||
))
|
||
or ("400" in _err_str_for_classify and len(history) > 50)
|
||
)
|
||
if is_context_overflow_failure:
|
||
logger.info(
|
||
"Skipping transcript persistence for context-overflow "
|
||
"failure in session %s to prevent session growth loop.",
|
||
session_entry.session_id,
|
||
)
|
||
elif agent_failed_early:
|
||
logger.info(
|
||
"Transient agent failure in session %s — persisting user "
|
||
"message so conversation context is preserved on retry.",
|
||
session_entry.session_id,
|
||
)
|
||
elif hidden_reasoning_incomplete:
|
||
logger.warning(
|
||
"Suppressing hidden-reasoning-only incomplete gateway turn "
|
||
"for session %s: %s",
|
||
session_entry.session_id,
|
||
agent_result.get("error", "processing incomplete"),
|
||
)
|
||
return agent_failed_early, hidden_reasoning_incomplete, is_context_overflow_failure
|
||
|
||
async def _hmwa_compression_exhaustion_reset(
|
||
self, agent_result, response, session_entry, session_key, source,
|
||
):
|
||
"""Auto-reset a permanently oversized session (never on a lock-contended defer). Returns
|
||
``(response, session_entry)``."""
|
||
# Compression exhausted = permanently too large: auto-reset so the next message starts
|
||
# fresh instead of replaying the oversized context forever. A lock-contended defer is
|
||
# the OPPOSITE case (a concurrent path holds the lock and is shrinking it): never wipe
|
||
# for that.
|
||
if agent_result.get("compression_deferred"):
|
||
logger.info(
|
||
"Compression deferred for session %s — the compression "
|
||
"lock is held by a concurrent compressor. Keeping the "
|
||
"session intact; the next message retries normally.",
|
||
session_entry.session_id if session_entry else "?",
|
||
)
|
||
elif agent_result.get("compression_exhausted") and session_entry and session_key:
|
||
logger.info(
|
||
"Auto-resetting session %s after compression exhaustion.",
|
||
session_entry.session_id,
|
||
)
|
||
new_entry = await self.async_session_store.reset_session(session_key)
|
||
self._evict_cached_agent(session_key)
|
||
# Conversation boundary: one funnel call clears every conversation-scoped
|
||
# per-session dict (see _CONVERSATION_SCOPED_STATE).
|
||
self._clear_conversation_scope(
|
||
session_key, reason="compression_exhausted_reset"
|
||
)
|
||
if new_entry is not None:
|
||
# Re-point the Telegram topic binding at the fresh session: compression rotated
|
||
# session_entry.session_id to the bloated child earlier this turn and that _sync
|
||
# also rewrote the (chat_id, thread_id) binding. Without a re-sync the
|
||
# binding-heal walk switches the next inbound message back onto the child and
|
||
# re-triggers exhaustion forever. No-op on non-topic lanes.
|
||
session_entry = new_entry
|
||
await asyncio.to_thread(
|
||
self._sync_telegram_topic_binding,
|
||
source, session_entry, reason="compression-exhausted-reset",
|
||
)
|
||
response = (response or "") + (
|
||
"\n\n🔄 Session auto-reset — the conversation exceeded the "
|
||
"maximum context size and could not be compressed further. "
|
||
"Your next message will start a fresh session."
|
||
)
|
||
return response, session_entry
|
||
|
||
async def _hmwa_persist_turn_transcript(
|
||
self, *, event, source, session_entry, session_key, agent_result, agent_messages,
|
||
history, response, message_text, persist_user_message, persist_user_timestamp,
|
||
persist_user_display_kind, agent_failed_early, hidden_reasoning_incomplete,
|
||
is_context_overflow_failure,
|
||
):
|
||
"""Persist this turn to the transcript (session_meta on first turn, user-only on transient
|
||
failure, nothing on context overflow), update last_prompt_tokens, and re-baseline the
|
||
cached agent's message count."""
|
||
from gateway.run import _resolve_gateway_model
|
||
ts = time.time() # Unix epoch float — consistent with DB storage
|
||
|
||
# Fresh session (no history): write the full tool definitions as the first entry so the
|
||
# transcript is self-describing — the same dicts sent as tools=[...] in the API request.
|
||
if is_context_overflow_failure:
|
||
pass # Skip all transcript writes — don't grow a broken session
|
||
elif not history:
|
||
tool_defs = agent_result.get("tools", [])
|
||
await self.async_session_store.append_to_transcript(
|
||
session_entry.session_id,
|
||
{
|
||
"role": "session_meta",
|
||
"tools": tool_defs or [],
|
||
"model": _resolve_gateway_model(),
|
||
"platform": source.platform.value if source.platform else "",
|
||
"timestamp": ts,
|
||
}
|
||
)
|
||
|
||
# The agent already persisted these via _flush_messages_to_session_db(); skip the DB
|
||
# write to avoid duplicates. Holds for the codex app-server runtime too (it flushes its
|
||
# own projected messages before returning and reports agent_persisted=True). Reading the
|
||
# flag (default = self._session_db is not None) keeps the contract explicit; a
|
||
# non-persisting runtime opts in via False.
|
||
agent_persisted = agent_result.get("agent_persisted", self._session_db is not None)
|
||
|
||
# Only the NEW messages from this turn: use history_offset (what the agent saw), not
|
||
# len(history), which counts session_meta entries stripped before the agent saw them.
|
||
if is_context_overflow_failure:
|
||
pass # handled above — skip all transcript writes
|
||
elif agent_failed_early or hidden_reasoning_incomplete:
|
||
# Transient failure (429/timeout/5xx): persist only the user message so the next
|
||
# message can load a transcript that reflects what was said. Skip the assistant
|
||
# error text since it's a gateway-generated hint, not model output. Hidden-reasoning
|
||
# incomplete turns follow the same rule so peer-agent channels don't ingest them.
|
||
_user_entry = {
|
||
"role": "user",
|
||
"content": (
|
||
persist_user_message
|
||
if persist_user_message is not None
|
||
else message_text
|
||
),
|
||
"timestamp": (
|
||
persist_user_timestamp
|
||
if persist_user_timestamp is not None
|
||
else ts
|
||
),
|
||
}
|
||
if persist_user_display_kind:
|
||
_user_entry["display_kind"] = persist_user_display_kind
|
||
if event.message_id:
|
||
_user_entry["message_id"] = str(event.message_id)
|
||
# Dedupe: skip if this platform message_id is already in the transcript (prevents
|
||
# duplicate user turns on Telegram retries after transient failures).
|
||
_skip_persist = (
|
||
event.message_id
|
||
and await self.async_session_store.has_platform_message_id(
|
||
session_entry.session_id, str(event.message_id)
|
||
)
|
||
)
|
||
if _skip_persist:
|
||
logger.info(
|
||
"Skipping duplicate user turn "
|
||
"(message_id=%s) in session %s",
|
||
event.message_id, session_entry.session_id,
|
||
)
|
||
else:
|
||
await self.async_session_store.append_to_transcript(
|
||
session_entry.session_id,
|
||
_user_entry,
|
||
skip_db=agent_persisted,
|
||
)
|
||
else:
|
||
history_len = agent_result.get("history_offset", len(history))
|
||
new_messages = agent_messages[history_len:] if len(agent_messages) > history_len else []
|
||
|
||
# If no new messages found (edge case), fall back to simple user/assistant
|
||
if not new_messages:
|
||
_user_entry = {
|
||
"role": "user",
|
||
"content": (
|
||
persist_user_message
|
||
if persist_user_message is not None
|
||
else message_text
|
||
),
|
||
"timestamp": (
|
||
persist_user_timestamp
|
||
if persist_user_timestamp is not None
|
||
else ts
|
||
),
|
||
}
|
||
if persist_user_display_kind:
|
||
_user_entry["display_kind"] = persist_user_display_kind
|
||
if event.message_id:
|
||
_user_entry["message_id"] = str(event.message_id)
|
||
await self.async_session_store.append_to_transcript(
|
||
session_entry.session_id,
|
||
_user_entry,
|
||
skip_db=agent_persisted,
|
||
)
|
||
if response:
|
||
await self.async_session_store.append_to_transcript(
|
||
session_entry.session_id,
|
||
{"role": "assistant", "content": response, "timestamp": ts},
|
||
skip_db=agent_persisted,
|
||
)
|
||
else:
|
||
# Attach the inbound platform message_id to the first user entry written this
|
||
# turn so platform-level quote-resolution (e.g. Yuanbao QuoteContextMiddleware's
|
||
# transcript fallback) can find earlier @bot messages by their original id.
|
||
_user_msg_id_attached = False
|
||
for msg in new_messages:
|
||
# Skip system messages (they're rebuilt each run)
|
||
if msg.get("role") == "system":
|
||
continue
|
||
# Add timestamp to each message for debugging
|
||
entry = {**msg, "timestamp": ts}
|
||
if (
|
||
not _user_msg_id_attached
|
||
and msg.get("role") == "user"
|
||
and event.message_id
|
||
and "message_id" not in entry
|
||
):
|
||
entry["message_id"] = str(event.message_id)
|
||
_user_msg_id_attached = True
|
||
await self.async_session_store.append_to_transcript(
|
||
session_entry.session_id, entry,
|
||
skip_db=agent_persisted,
|
||
)
|
||
|
||
# The agent persists token counts and model itself; keep only last_prompt_tokens here
|
||
# for context-window tracking and compression decisions.
|
||
await self.async_session_store.update_session(
|
||
session_entry.session_key,
|
||
last_prompt_tokens=agent_result.get("last_prompt_tokens", 0),
|
||
touch_activity=not bool(getattr(event, "internal", False)),
|
||
)
|
||
|
||
# Re-baseline the cached agent's message_count snapshot now that ALL of this turn's
|
||
# transcript writes are done (flushed rows AND the first-turn `session_meta` marker).
|
||
# The cross-process coherence guard snapshots at agent-BUILD time and never refreshes
|
||
# on reuse, so our own writes would trigger a rebuild next turn (destroying prompt
|
||
# caching). MUST run after the session_meta append (that row bumps the count too).
|
||
await self._refresh_agent_cache_message_count(
|
||
session_key, session_entry.session_id
|
||
)
|
||
|
||
async def _hmwa_deliver_turn_response(
|
||
self, event, source, session_entry, session_key, run_generation,
|
||
agent_result, agent_messages, response, _footer_line, _intentional_silence,
|
||
):
|
||
"""Final delivery decisions: intentional silence, voice reply, streamed-turn media/footer.
|
||
Returns the text for the adapter to send, or ``None`` when already delivered."""
|
||
# Intentional silence is a delivery decision, not a transcript mutation: the [SILENT]
|
||
# assistant turn stays persisted so later turns keep user/assistant alternation; only
|
||
# the outbound delivery is suppressed.
|
||
if _intentional_silence:
|
||
logger.info(
|
||
"Suppressing intentional silence marker for session %s",
|
||
session_entry.session_id,
|
||
)
|
||
response = ""
|
||
|
||
# Auto voice reply: send TTS audio before the text response
|
||
_already_sent = bool(agent_result.get("already_sent"))
|
||
# Skip when streaming TTS already delivered audio for this turn (#60671).
|
||
_stts_adapter = self._adapter_for_source(source)
|
||
_streaming_tts_done = (
|
||
_stts_adapter is not None
|
||
and bool(getattr(_stts_adapter, "_streaming_tts_turn_completed", lambda *_a, **_k: False)(session_key, run_generation))
|
||
)
|
||
if (
|
||
not _streaming_tts_done
|
||
and self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent)
|
||
):
|
||
await self._send_voice_reply(event, response)
|
||
|
||
# Streamed responses still need MEDIA: files delivered before returning None (chunks
|
||
# carry the tags verbatim and post-processing is skipped when already_sent). Never skip
|
||
# when the agent failed: the error text is new content streaming didn't show.
|
||
if agent_result.get("already_sent") and not agent_result.get("failed"):
|
||
if response:
|
||
_media_adapter = self._adapter_for_source(source)
|
||
if _media_adapter:
|
||
await self._deliver_media_from_response(
|
||
response, event, _media_adapter,
|
||
)
|
||
# Streaming already delivered the body text, but the footer was intentionally held
|
||
# back (see the `not already_sent` gate above).
|
||
if _footer_line:
|
||
try:
|
||
_foot_adapter = self._adapter_for_source(source)
|
||
if _foot_adapter:
|
||
await _foot_adapter.send(
|
||
source.chat_id,
|
||
_footer_line,
|
||
metadata=self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)),
|
||
)
|
||
except Exception as _e:
|
||
logger.debug("trailing footer send failed: %s", _e)
|
||
# This branch returns None so the adapter does not send the body twice. /loop and
|
||
# /goal hooks in _handle_message read the return value, so stash the delivered text
|
||
# on the event or those hooks never run and a /loop tick stays awaiting.
|
||
with suppress(Exception):
|
||
event._streamed_final_response = str(response or "")
|
||
return None
|
||
|
||
return response
|
||
|
||
async def _hmwa_agent_error_reply(
|
||
self, e, event, source, session_entry, session_key, history, message_text,
|
||
persist_user_message, persist_user_timestamp, persist_user_display_kind,
|
||
):
|
||
"""``except Exception`` body of the agent turn: stop typing, log, persist the inbound user
|
||
turn once, and build the sanitized user-facing error reply."""
|
||
# Stop typing indicator on error too, retaining Slack thread/workspace
|
||
# routing so a failed turn cannot leave its status visible.
|
||
await self._hmwa_stop_typing_for_turn(event, source)
|
||
logger.exception("Agent error in session %s", session_key)
|
||
# Crash-resilience for failures before AIAgent enters run_conversation() (e.g. provider/
|
||
# httpx client init): the agent can't persist the inbound turn there, so append the user
|
||
# message here once; if the agent already reached turn-start persistence the latest user
|
||
# row matches and we skip the duplicate.
|
||
try:
|
||
if message_text is not None and session_entry is not None:
|
||
_already_persisted = False
|
||
try:
|
||
_recent_transcript = await self.async_session_store.load_transcript(session_entry.session_id)
|
||
except Exception:
|
||
_recent_transcript = []
|
||
for _msg in reversed(_recent_transcript[-10:]):
|
||
if _msg.get("role") == "user":
|
||
_expected_user_content = (
|
||
persist_user_message
|
||
if persist_user_message is not None
|
||
else message_text
|
||
)
|
||
_already_persisted = (_msg.get("content") == _expected_user_content)
|
||
break
|
||
if not _already_persisted:
|
||
_user_entry = {
|
||
"role": "user",
|
||
"content": (
|
||
persist_user_message
|
||
if persist_user_message is not None
|
||
else message_text
|
||
),
|
||
"timestamp": (
|
||
persist_user_timestamp
|
||
if persist_user_timestamp is not None
|
||
else time.time()
|
||
),
|
||
}
|
||
if persist_user_display_kind:
|
||
_user_entry["display_kind"] = persist_user_display_kind
|
||
if getattr(event, "message_id", None):
|
||
_user_entry["message_id"] = str(event.message_id)
|
||
await self.async_session_store.append_to_transcript(
|
||
session_entry.session_id,
|
||
_user_entry,
|
||
)
|
||
except Exception:
|
||
logger.debug("Failed to persist inbound user message after agent exception", exc_info=True)
|
||
# Log full details server-side only; never expose raw exception
|
||
# types or messages to end users (info-leakage risk).
|
||
status_hint = ""
|
||
status_code = getattr(e, "status_code", None)
|
||
_hist_len = len(history)
|
||
if status_code == 401:
|
||
status_hint = " Check your API key or run `claude /login` to refresh OAuth credentials."
|
||
elif status_code == 402:
|
||
status_hint = " Your API balance or quota is exhausted. Check your provider dashboard."
|
||
elif status_code == 429:
|
||
# Check if this is a plan usage limit (resets on a schedule) vs a transient rate limit
|
||
_err_body = getattr(e, "response", None)
|
||
_err_json = {}
|
||
try:
|
||
if _err_body is not None:
|
||
_err_json = _err_body.json().get("error", {})
|
||
if not isinstance(_err_json, dict):
|
||
_err_json = {}
|
||
except Exception:
|
||
pass
|
||
if _err_json.get("type") == "usage_limit_reached":
|
||
_resets_in = _err_json.get("resets_in_seconds")
|
||
if _resets_in and _resets_in > 0:
|
||
import math
|
||
_hours = math.ceil(_resets_in / 3600)
|
||
status_hint = f" Your plan's usage limit has been reached. It resets in ~{_hours}h."
|
||
else:
|
||
status_hint = " Your plan's usage limit has been reached. Please wait until it resets."
|
||
else:
|
||
status_hint = " You are being rate-limited. Please wait a moment and try again."
|
||
elif status_code == 529:
|
||
status_hint = " The API is temporarily overloaded. Please try again shortly."
|
||
elif status_code in {400, 500}:
|
||
# 400 on a large session is context overflow; 500 on a large session often means the
|
||
# payload is too large for the API — treat it the same way.
|
||
if _hist_len > 50:
|
||
return (
|
||
"⚠️ Session too large for the model's context window.\n"
|
||
"Use /compact to compress the conversation, or "
|
||
"/reset to start fresh."
|
||
)
|
||
elif status_code == 400:
|
||
status_hint = " The request was rejected by the API."
|
||
return (
|
||
f"Sorry, I encountered an unexpected error.{status_hint}\n"
|
||
"Try again or use /reset to start a fresh session."
|
||
)
|
||
|
||
async def _handle_message_with_agent(self, event, source, _quick_key: str, run_generation: int):
|
||
"""Inner handler that runs under the _running_agents sentinel guard."""
|
||
from gateway.run import _load_gateway_config
|
||
_msg_start_time = time.time()
|
||
_platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
|
||
_msg_preview = (event.text or "")[:80].replace("\n", " ")
|
||
_reply_id = getattr(event, "reply_to_message_id", None)
|
||
_reply_txt = (getattr(event, "reply_to_text", None) or "")[:80].replace("\n", " ")
|
||
logger.info(
|
||
"inbound message: platform=%s user=%s chat=%s msg=%r reply_to_id=%s reply_to_text=%r",
|
||
_platform_name, source.user_name or source.user_id or "unknown",
|
||
source.chat_id or "unknown", _msg_preview, _reply_id, _reply_txt,
|
||
)
|
||
|
||
resolved = await self._hmwa_resolve_session(event, source)
|
||
if resolved is None:
|
||
return
|
||
source, session_entry, session_key = resolved
|
||
_was_auto_reset, _is_new_session = await self._hmwa_open_session(
|
||
session_entry, session_key, source
|
||
)
|
||
|
||
# Build session context
|
||
context = build_session_context(source, self.config, session_entry)
|
||
|
||
# Set session context variables for tools (task-local, concurrency-safe)
|
||
_session_env_tokens = self._set_session_env(context)
|
||
|
||
# Read privacy.redact_pii from config (re-read per message)
|
||
_redact_pii = False
|
||
# Synthetic self-injected turns (batch completions, watch notifications, resume wake-ups)
|
||
# arrive as MessageEvent(internal=True). Persist with display_kind="internal_notification"
|
||
# so UIs render timeline notices, not user bubbles. display_kind is a DB-only sidecar
|
||
# stripped from every provider-bound payload; role/content untouched.
|
||
persist_user_display_kind = (
|
||
"internal_notification" if getattr(event, "internal", False) else None
|
||
)
|
||
try:
|
||
_pcfg = _load_gateway_config()
|
||
_redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False))
|
||
except Exception:
|
||
pass
|
||
|
||
# Build the context prompt. The render is pinned per session, keyed by a hash of the exact
|
||
# renderer inputs (_ephemeral_change_key): a hit reuses the pinned bytes so the system prompt
|
||
# cannot drift turn-over-turn; a miss (thread rename, /sethome, redact_pii flip) re-renders.
|
||
context_prompt = self._pinned_session_context_prompt(
|
||
context, _redact_pii, session_key
|
||
)
|
||
|
||
# Per-turn must-deliver notes ride the user message via the api_content sidecar (staged
|
||
# below, consumed in run_sync → build_turn_context), NOT context_prompt: appending them to
|
||
# the ephemeral system prompt guaranteed a turn1→turn2 diff and a full agent rebuild.
|
||
turn_sidecar_notes: List[str] = []
|
||
|
||
# If the previous session expired and was auto-reset, deliver a notice
|
||
# so the agent knows this is a fresh conversation (not an intentional /reset).
|
||
if _was_auto_reset:
|
||
await self._hmwa_deliver_auto_reset_notice(session_entry, source, turn_sidecar_notes)
|
||
|
||
# Auto-load skill(s) for topic/channel bindings (Telegram DM Topics, Discord
|
||
# channel_skill_bindings). Supports a single name or ordered list. Only inject on NEW
|
||
# sessions — ongoing conversations already carry the skill content in their history.
|
||
_auto = getattr(event, "auto_skill", None)
|
||
if _is_new_session and _auto:
|
||
self._hmwa_auto_load_skills(event, _auto, _quick_key, session_key)
|
||
|
||
# Turn lease: session resolution is FINAL here (see _hmwa_acquire_turn_lease).
|
||
await self._hmwa_acquire_turn_lease(
|
||
_quick_key, run_generation, session_entry, _session_env_tokens
|
||
)
|
||
|
||
# A turn only becomes durable recovery work after it owns (or has explicitly degraded past)
|
||
# the per-session lease. Marking before the await above would falsely recover an alias-
|
||
# routed message that never began processing if the gateway died while it was still waiting.
|
||
await self._mark_durable_active_turn(event, session_entry.session_key)
|
||
|
||
# Load conversation history from transcript. An unreadable canonical store is not an empty
|
||
# conversation: stop before the agent can invent continuity from a plausible-looking [].
|
||
# This return happens before the broad cleanup finally below, so restore task-local context
|
||
# here; the outer dispatch still clears the durable marker and turn lease.
|
||
try:
|
||
history = await self.async_session_store.load_transcript(
|
||
session_entry.session_id
|
||
)
|
||
except TranscriptReadError:
|
||
self._clear_session_env(_session_env_tokens)
|
||
return (
|
||
"⚠️ This session's history is temporarily unavailable, so "
|
||
"this message was not processed. Ask the operator to inspect "
|
||
"state.db, then resend after it is healthy. Use /reset only "
|
||
"if you intentionally want to start a new conversation."
|
||
)
|
||
|
||
# Session hygiene: auto-compress pathologically large transcripts before the agent starts.
|
||
history = await self._hmwa_run_session_hygiene(
|
||
event, source, session_entry, session_key, history, _quick_key, run_generation,
|
||
)
|
||
|
||
await self._hmwa_first_contact_notes(source, history, turn_sidecar_notes)
|
||
|
||
# Voice channel awareness: deliver voice channel state (who is present / speaking) on the
|
||
# user message, ONLY when changed since the previous turn. It differs almost every turn, and
|
||
# in the ephemeral system prompt it forced a full agent rebuild + prompt-cache re-key per
|
||
# message; the system prompt carries a static pointer line instead (gateway/session.py).
|
||
_vc_note = self._voice_channel_sidecar_note(event, source, session_key)
|
||
if _vc_note:
|
||
turn_sidecar_notes.append(_vc_note)
|
||
|
||
# Auto-analyze user images: run the vision tool eagerly so the model always gets a text
|
||
# description plus the local path for re-examination via vision_analyze. Filter to image
|
||
# media_type so documents/audio in the same message are not sent to the vision tool.
|
||
message_text = await self._prepare_profile_scoped_inbound_message_text(
|
||
event=event,
|
||
source=source,
|
||
history=history,
|
||
session_key=session_key,
|
||
)
|
||
if message_text is None:
|
||
return
|
||
|
||
# Capture the platform event time as message metadata and keep the persisted transcript
|
||
# clean; only the in-context render is gated behind gateway.message_timestamps.enabled.
|
||
message_text, persist_user_message, persist_user_timestamp = (
|
||
self._hmwa_apply_message_timestamp(event, message_text)
|
||
)
|
||
|
||
# Stage this turn's must-deliver notes (one-shot; consumed in run_sync) AFTER the
|
||
# message_text early-out so an aborted turn cannot leak its notes into the next turn.
|
||
if turn_sidecar_notes and session_key:
|
||
self._set_pending_turn_sidecar_notes(session_key, turn_sidecar_notes)
|
||
|
||
# Bind this run generation to the adapter's active-session event so deferred post-delivery
|
||
# callbacks can be released by the same run that registered them.
|
||
self._bind_adapter_run_generation(
|
||
self._adapter_for_source(source),
|
||
session_key,
|
||
run_generation,
|
||
)
|
||
|
||
try:
|
||
# Emit agent:start hook
|
||
hook_ctx = {
|
||
"platform": source.platform.value if source.platform else "",
|
||
"user_id": source.user_id,
|
||
"chat_id": source.chat_id or "",
|
||
"thread_id": str(getattr(source, "thread_id", None)) if getattr(source, "thread_id", None) else "",
|
||
"chat_type": getattr(source, "chat_type", "") or "",
|
||
"session_id": session_entry.session_id,
|
||
"message": message_text[:500],
|
||
}
|
||
await self.hooks.emit("agent:start", hook_ctx)
|
||
|
||
# Run the agent. Capture the session id that this run was launched against so post-run
|
||
# compression publication can be identity-guarded below; a /new or another lifecycle
|
||
# transition may move session_entry.session_id while the old run is still unwinding.
|
||
_run_start_session_id = session_entry.session_id
|
||
_turn_started_monotonic = time.monotonic()
|
||
agent_result = await self._run_agent(
|
||
message=message_text,
|
||
context_prompt=context_prompt,
|
||
history=history,
|
||
source=source,
|
||
session_id=_run_start_session_id,
|
||
session_key=session_key,
|
||
run_generation=run_generation,
|
||
event_message_id=self._reply_anchor_for_event(event),
|
||
inbound_message_id=(
|
||
str(event.message_id) if event.message_id else None
|
||
),
|
||
channel_prompt=event.channel_prompt,
|
||
moa_config=getattr(event, "_moa_config", None),
|
||
persist_user_message=persist_user_message,
|
||
persist_user_timestamp=persist_user_timestamp,
|
||
persist_user_display_kind=persist_user_display_kind,
|
||
message_type=event.message_type,
|
||
)
|
||
_turn_seconds = time.monotonic() - _turn_started_monotonic
|
||
|
||
await self._hmwa_stop_typing_for_turn(event, source)
|
||
|
||
if not self._is_session_run_current(_quick_key, run_generation):
|
||
logger.info(
|
||
"Discarding stale agent result for %s — generation %d is no longer current",
|
||
_quick_key or "?",
|
||
run_generation,
|
||
)
|
||
_stale_adapter = self._adapter_for_source(source)
|
||
if getattr(type(_stale_adapter), "pop_post_delivery_callback", None) is not None:
|
||
_stale_adapter.pop_post_delivery_callback(
|
||
_quick_key,
|
||
generation=run_generation,
|
||
)
|
||
elif _stale_adapter and hasattr(_stale_adapter, "_post_delivery_callbacks"):
|
||
_stale_adapter._post_delivery_callbacks.pop(_quick_key, None)
|
||
return None
|
||
|
||
response, _intentional_silence, agent_messages = await self._hmwa_shape_agent_response(
|
||
agent_result, source, history, session_entry, session_key,
|
||
_quick_key, run_generation, _run_start_session_id, _platform_name, _msg_start_time,
|
||
)
|
||
response = self._hmwa_prepend_reasoning(agent_result, response, source, _intentional_silence)
|
||
_footer_line = self._hmwa_runtime_footer_line(agent_result, source, _turn_seconds)
|
||
if _footer_line and response and not agent_result.get("already_sent") and not _intentional_silence:
|
||
response = f"{response}\n\n{_footer_line}"
|
||
await self._hmwa_post_turn_hooks(hook_ctx, agent_result, response)
|
||
|
||
agent_failed_early, hidden_reasoning_incomplete, is_context_overflow_failure = (
|
||
self._hmwa_classify_turn_failure(agent_result, history, session_entry)
|
||
)
|
||
response, session_entry = await self._hmwa_compression_exhaustion_reset(
|
||
agent_result, response, session_entry, session_key, source,
|
||
)
|
||
await self._hmwa_persist_turn_transcript(
|
||
event=event,
|
||
source=source,
|
||
session_entry=session_entry,
|
||
session_key=session_key,
|
||
agent_result=agent_result,
|
||
agent_messages=agent_messages,
|
||
history=history,
|
||
response=response,
|
||
message_text=message_text,
|
||
persist_user_message=persist_user_message,
|
||
persist_user_timestamp=persist_user_timestamp,
|
||
persist_user_display_kind=persist_user_display_kind,
|
||
agent_failed_early=agent_failed_early,
|
||
hidden_reasoning_incomplete=hidden_reasoning_incomplete,
|
||
is_context_overflow_failure=is_context_overflow_failure,
|
||
)
|
||
return await self._hmwa_deliver_turn_response(
|
||
event, source, session_entry, session_key, run_generation,
|
||
agent_result, agent_messages, response, _footer_line, _intentional_silence,
|
||
)
|
||
|
||
except Exception as e:
|
||
return await self._hmwa_agent_error_reply(
|
||
e, event, source, session_entry, session_key, history, message_text,
|
||
persist_user_message, persist_user_timestamp, persist_user_display_kind,
|
||
)
|
||
finally:
|
||
# Restore session context variables to their pre-handler state
|
||
self._clear_session_env(_session_env_tokens)
|
||
|
||
def _reset_notice_session_info(self, source: SessionSource) -> str:
|
||
"""Session-info block for the auto-reset notice, profile-scoped.
|
||
|
||
Under multiplexing, resolve model/provider/context inside the profile serving ``source``
|
||
(mirrors ``_run_agent``'s gating) or the banner advertises the base config's model. Call
|
||
via ``asyncio.to_thread``: resolution can block (credential refresh, context-length
|
||
probes), and the scope is entered here so contextvars behave in the worker thread.
|
||
"""
|
||
from gateway.run import _profile_runtime_scope
|
||
if getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
||
with _profile_runtime_scope(self._resolve_profile_home_for_source(source)):
|
||
return self._format_session_info()
|
||
return self._format_session_info()
|
||
|
||
def _format_session_info(self) -> str:
|
||
"""Resolve current model config and return a formatted info block.
|
||
|
||
Surfaces model, provider, context length, and endpoint so gateway users can immediately
|
||
see if context detection went wrong (e.g. local models falling to the 128K default).
|
||
"""
|
||
from gateway.run import _resolve_gateway_model_context
|
||
resolved = _resolve_gateway_model_context()
|
||
model = resolved.model
|
||
provider = resolved.provider
|
||
base_url = resolved.base_url
|
||
context_length = resolved.context_length
|
||
|
||
# Format context source hint
|
||
if resolved.context_source == "config":
|
||
ctx_source = "config"
|
||
elif resolved.context_source == "default":
|
||
ctx_source = "default — set model.context_length in config to override"
|
||
else:
|
||
ctx_source = "detected"
|
||
|
||
# Format context length for display
|
||
if context_length >= 1_000_000:
|
||
ctx_display = f"{context_length / 1_000_000:.1f}M"
|
||
elif context_length >= 1_000:
|
||
ctx_display = f"{context_length // 1_000}K"
|
||
else:
|
||
ctx_display = str(context_length)
|
||
|
||
lines = [
|
||
f"◆ Model: `{model}`",
|
||
f"◆ Provider: {provider or 'openrouter'}",
|
||
f"◆ Context: {ctx_display} tokens ({ctx_source})",
|
||
]
|
||
|
||
# Show endpoint for local/custom setups
|
||
if base_url and base_url_hostname(base_url) in ("localhost", "127.0.0.1", "0.0.0.0"):
|
||
lines.append(f"◆ Endpoint: {base_url}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
async def _run_background_task(
|
||
self,
|
||
prompt: str,
|
||
source: "SessionSource",
|
||
task_id: str,
|
||
event_message_id: Optional[str] = None,
|
||
media_urls: Optional[List[str]] = None,
|
||
media_types: Optional[List[str]] = None,
|
||
) -> None:
|
||
"""Profile-scoping wrapper around the background agent task.
|
||
|
||
When multiplexing is active, resolve the inbound source's profile and run the whole task
|
||
inside ``_profile_runtime_scope`` so credentials resolve from that profile's secret
|
||
scope. Mirrors the pattern in ``_run_agent``.
|
||
"""
|
||
from gateway.run import _profile_runtime_scope
|
||
if not getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
||
return await self._run_background_task_inner(
|
||
prompt, source, task_id, event_message_id, media_urls, media_types,
|
||
)
|
||
|
||
profile_home = self._resolve_profile_home_for_source(source)
|
||
with _profile_runtime_scope(profile_home):
|
||
return await self._run_background_task_inner(
|
||
prompt, source, task_id, event_message_id, media_urls, media_types,
|
||
)
|
||
|
||
def _resolve_enabled_toolsets_for_source(
|
||
self,
|
||
user_config: dict,
|
||
source: "SessionSource",
|
||
platform_key: str,
|
||
) -> list:
|
||
"""Resolve enabled toolsets for an agent run, honoring per-source overrides.
|
||
|
||
An adapter ``toolsets_for_source()`` override (e.g. per-route webhook toolsets) is
|
||
validated through the SAME ``_get_platform_tools`` path as normal platform config, so
|
||
unknown and platform-restricted toolsets are dropped rather than trusted. Absent an
|
||
override, falls back to ``platform_toolsets.<platform>``.
|
||
"""
|
||
from hermes_cli.tools_config import _get_platform_tools
|
||
|
||
override = None
|
||
try:
|
||
adapter = self._adapter_for_source(source)
|
||
if adapter is not None:
|
||
override = adapter.toolsets_for_source(source)
|
||
except Exception:
|
||
override = None
|
||
|
||
if override and isinstance(override, list):
|
||
cfg = dict(user_config)
|
||
pts = dict(cfg.get("platform_toolsets") or {})
|
||
pts[platform_key] = [str(t) for t in override]
|
||
cfg["platform_toolsets"] = pts
|
||
return sorted(_get_platform_tools(cfg, platform_key))
|
||
|
||
return sorted(_get_platform_tools(user_config, platform_key))
|
||
|
||
async def _run_background_task_inner(
|
||
self,
|
||
prompt: str,
|
||
source: "SessionSource",
|
||
task_id: str,
|
||
event_message_id: Optional[str] = None,
|
||
media_urls: Optional[List[str]] = None,
|
||
media_types: Optional[List[str]] = None,
|
||
) -> None:
|
||
"""Execute a background agent task and deliver the result to the chat."""
|
||
from gateway.run import (
|
||
_checkpoint_agent_kwargs,
|
||
_current_max_iterations,
|
||
_load_gateway_config,
|
||
_platform_config_key,
|
||
)
|
||
from run_agent import AIAgent
|
||
|
||
media_urls = media_urls or []
|
||
media_types = media_types or []
|
||
|
||
adapter = self._adapter_for_source(source)
|
||
if not adapter:
|
||
logger.warning("No adapter for platform %s in background task %s", source.platform, task_id)
|
||
return
|
||
|
||
_thread_metadata = self._thread_metadata_for_source(source, event_message_id)
|
||
|
||
try:
|
||
user_config = _load_gateway_config()
|
||
model, runtime_kwargs = self._resolve_session_agent_runtime(
|
||
source=source,
|
||
user_config=user_config,
|
||
)
|
||
if not runtime_kwargs.get("api_key"):
|
||
await adapter.send(
|
||
source.chat_id,
|
||
f"❌ Background task {task_id} failed: no provider credentials configured.",
|
||
metadata=_thread_metadata,
|
||
)
|
||
return
|
||
|
||
platform_key = _platform_config_key(source.platform)
|
||
|
||
enabled_toolsets = self._resolve_enabled_toolsets_for_source(
|
||
user_config, source, platform_key
|
||
)
|
||
agent_cfg = user_config.get("agent") or {}
|
||
from agent.skill_utils import parse_config_string_list
|
||
|
||
disabled_toolsets = parse_config_string_list(agent_cfg.get("disabled_toolsets")) or None
|
||
|
||
pr = self._provider_routing
|
||
max_iterations = _current_max_iterations()
|
||
reasoning_config = self._resolve_session_reasoning_config(
|
||
source=source, model=model
|
||
)
|
||
self._reasoning_config = reasoning_config
|
||
self._service_tier = self._resolve_session_service_tier(source=source)
|
||
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
|
||
|
||
# Enrich the prompt with image descriptions so the background
|
||
# agent can see user-attached images (same as the main flow).
|
||
enriched_prompt = prompt
|
||
if media_urls:
|
||
image_paths = []
|
||
for i, path in enumerate(media_urls):
|
||
mtype = media_types[i] if i < len(media_types) else ""
|
||
if mtype.startswith("image/"):
|
||
image_paths.append(path)
|
||
if image_paths:
|
||
try:
|
||
enriched_prompt = await self._enrich_message_with_vision(
|
||
prompt, image_paths,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("Background task vision enrichment failed: %s", e)
|
||
|
||
def run_sync():
|
||
agent = AIAgent(
|
||
model=turn_route["model"],
|
||
**turn_route["runtime"],
|
||
**_checkpoint_agent_kwargs(user_config),
|
||
max_iterations=max_iterations,
|
||
quiet_mode=True,
|
||
verbose_logging=False,
|
||
enabled_toolsets=enabled_toolsets,
|
||
disabled_toolsets=disabled_toolsets,
|
||
reasoning_config=reasoning_config,
|
||
service_tier=self._service_tier,
|
||
request_overrides=turn_route.get("request_overrides"),
|
||
providers_allowed=pr.get("only"),
|
||
providers_ignored=pr.get("ignore"),
|
||
providers_order=pr.get("order"),
|
||
provider_sort=pr.get("sort"),
|
||
provider_require_parameters=pr.get("require_parameters", False),
|
||
provider_data_collection=pr.get("data_collection"),
|
||
session_id=task_id,
|
||
platform=platform_key,
|
||
user_id=source.user_id,
|
||
user_id_alt=source.user_id_alt,
|
||
user_name=source.user_name,
|
||
chat_id=source.chat_id,
|
||
chat_name=source.chat_name,
|
||
chat_type=source.chat_type,
|
||
thread_id=source.thread_id,
|
||
session_db=getattr(self._session_db, "_db", self._session_db),
|
||
# Reload from disk — do not reuse the startup snapshot (#60955).
|
||
fallback_model=self._refresh_fallback_model(),
|
||
)
|
||
try:
|
||
return agent.run_conversation(
|
||
user_message=enriched_prompt,
|
||
task_id=task_id,
|
||
)
|
||
finally:
|
||
self._cleanup_agent_resources(agent)
|
||
|
||
result = await self._run_in_executor_with_context(run_sync)
|
||
|
||
response = result.get("final_response", "") if result else ""
|
||
if not response and result and result.get("error"):
|
||
response = f"Error: {result['error']}"
|
||
|
||
# Background tasks start a fresh conversation, so history_offset=0: every message in the
|
||
# run belongs to this turn. Mirrors the repair on the main turn path.
|
||
if response:
|
||
response = repair_explicit_computer_use_media_paths(
|
||
response,
|
||
result.get("messages", []),
|
||
)
|
||
|
||
# Extract media files from the response
|
||
if response:
|
||
media_files, response = adapter.extract_media(response)
|
||
from gateway.platforms.base import BasePlatformAdapter
|
||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||
images, text_content = adapter.extract_images(response)
|
||
|
||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||
header = f'✅ Background task complete\nPrompt: "{preview}"\n\n'
|
||
|
||
if text_content:
|
||
await adapter.send(
|
||
chat_id=source.chat_id,
|
||
content=header + text_content,
|
||
metadata=_thread_metadata,
|
||
)
|
||
elif not images and not media_files:
|
||
await adapter.send(
|
||
chat_id=source.chat_id,
|
||
content=header + "(No response generated)",
|
||
metadata=_thread_metadata,
|
||
)
|
||
|
||
# Send extracted images
|
||
for image_url, alt_text in (images or []):
|
||
with suppress(Exception):
|
||
await adapter.send_image(
|
||
chat_id=source.chat_id,
|
||
image_url=image_url,
|
||
caption=alt_text,
|
||
metadata=_thread_metadata,
|
||
)
|
||
|
||
# Route each media file by type so a TTS clip arrives as a voice bubble and a clip
|
||
# as a video rather than a generic document. Mirrors the streaming + kanban paths.
|
||
from gateway.platforms.base import (
|
||
should_send_media_as_audio as _should_send_media_as_audio,
|
||
)
|
||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||
for media_path, _is_voice in (media_files or []):
|
||
_ext = os.path.splitext(media_path)[1].lower()
|
||
try:
|
||
if _should_send_media_as_audio(source.platform, _ext, _is_voice):
|
||
await adapter.send_voice(
|
||
chat_id=source.chat_id,
|
||
audio_path=media_path,
|
||
metadata=_thread_metadata,
|
||
is_voice=_is_voice,
|
||
)
|
||
elif _ext in _VIDEO_EXTS:
|
||
await adapter.send_video(
|
||
chat_id=source.chat_id,
|
||
video_path=media_path,
|
||
metadata=_thread_metadata,
|
||
)
|
||
elif _ext in _IMAGE_EXTS:
|
||
await adapter.send_image_file(
|
||
chat_id=source.chat_id,
|
||
image_path=media_path,
|
||
metadata=_thread_metadata,
|
||
)
|
||
else:
|
||
await adapter.send_document(
|
||
chat_id=source.chat_id,
|
||
file_path=media_path,
|
||
metadata=_thread_metadata,
|
||
)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||
await adapter.send(
|
||
chat_id=source.chat_id,
|
||
content=f'✅ Background task complete\nPrompt: "{preview}"\n\n(No response generated)',
|
||
metadata=_thread_metadata,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.exception("Background task %s failed", task_id)
|
||
with suppress(Exception):
|
||
await adapter.send(
|
||
chat_id=source.chat_id,
|
||
content=f"❌ Background task {task_id} failed: {e}",
|
||
metadata=_thread_metadata,
|
||
)
|
||
|
||
async def _execute_mcp_reload(self, event: MessageEvent) -> str:
|
||
"""Actually disconnect, reconnect, and notify MCP tool changes.
|
||
|
||
Split out so the confirmation wrapper can invoke the same path for button, text reply,
|
||
or disabled confirm gate. Under multiplex the reload runs inside the requesting profile's
|
||
runtime scope (entered here when the caller did not) and only that profile's servers are
|
||
torn down and rediscovered.
|
||
"""
|
||
from gateway.run import _profile_runtime_scope
|
||
multiplex = bool(getattr(self.config, "multiplex_profiles", False))
|
||
if multiplex and not get_hermes_home_override():
|
||
profile_home = self._resolve_profile_home_for_source(event.source)
|
||
with _profile_runtime_scope(Path(profile_home)):
|
||
return await self._execute_mcp_reload(event)
|
||
try:
|
||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
|
||
from tools.mcp_tool import _server_scope_keys, reprobe_tool_availability
|
||
from tools.registry import registry
|
||
|
||
reload_scope = registry.current_scope_key() if multiplex else None
|
||
|
||
def _scoped_server_names() -> set:
|
||
with _lock:
|
||
return {
|
||
name for name in _servers
|
||
if reload_scope is None or _server_scope_keys.get(name) == reload_scope
|
||
}
|
||
|
||
# Capture old server names before shutdown
|
||
old_servers = _scoped_server_names()
|
||
|
||
# Read new config before shutting down, so we know what will be added/removed
|
||
# Shutdown existing connections
|
||
await self._run_in_executor_with_context(
|
||
lambda: shutdown_mcp_servers(scope=reload_scope)
|
||
)
|
||
# Explicit reload also re-probes tool availability (check_fn).
|
||
reprobe_tool_availability()
|
||
|
||
# Reconnect by discovering tools (reads config.yaml fresh)
|
||
new_tools = await self._run_in_executor_with_context(discover_mcp_tools)
|
||
|
||
# Compute what changed
|
||
connected_servers = _scoped_server_names()
|
||
if reload_scope is not None:
|
||
from tools.mcp_tool import _mcp_tool_server_names
|
||
|
||
with _lock:
|
||
new_tools = [
|
||
n for n in new_tools
|
||
if _mcp_tool_server_names.get(n) in connected_servers
|
||
]
|
||
|
||
added = connected_servers - old_servers
|
||
removed = old_servers - connected_servers
|
||
reconnected = connected_servers & old_servers
|
||
|
||
lines = [t("gateway.reload_mcp.header")]
|
||
if reconnected:
|
||
lines.append(t("gateway.reload_mcp.reconnected", names=", ".join(sorted(reconnected))))
|
||
if added:
|
||
lines.append(t("gateway.reload_mcp.added", names=", ".join(sorted(added))))
|
||
if removed:
|
||
lines.append(t("gateway.reload_mcp.removed", names=", ".join(sorted(removed))))
|
||
if not connected_servers:
|
||
lines.append(t("gateway.reload_mcp.none_connected"))
|
||
else:
|
||
lines.append(t("gateway.reload_mcp.tools_available", tools=len(new_tools), servers=len(connected_servers)))
|
||
|
||
# Refresh cached agents so existing sessions see new MCP tools on their next turn —
|
||
# without this, the user has to `/new` (which discards conversation history) to pick up
|
||
# tools from a server that was just added or reconnected.
|
||
try:
|
||
from tools.mcp_tool import refresh_agent_mcp_tools
|
||
_cache = getattr(self, "_agent_cache", None)
|
||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||
if _cache_lock is not None and _cache:
|
||
# Multiplex: only this profile's sessions; rebuilding another profile's agent in
|
||
# this scope would hand it this profile's tool registry.
|
||
_ns_prefix = (
|
||
_session_key_namespace(event.source.profile) + ":"
|
||
if multiplex else None
|
||
)
|
||
with _cache_lock:
|
||
for _sess_key, _entry in list(_cache.items()):
|
||
if _ns_prefix and not str(_sess_key).startswith(_ns_prefix):
|
||
continue
|
||
try:
|
||
_agent = _entry[0] if isinstance(_entry, tuple) else _entry
|
||
except Exception:
|
||
continue
|
||
if _agent is None:
|
||
continue
|
||
# Preserve each cached agent's build-time toolset selection EXACTLY: a
|
||
# gateway session built with a restricted enabled_toolsets (e.g.
|
||
# ["safe"]) must NOT silently gain tools after a reload. Unlike the
|
||
# CLI/TUI /reload-mcp (one user re-applying their own config), gateway
|
||
# agents are per-session and may be deliberately locked down.
|
||
refresh_agent_mcp_tools(_agent, quiet_mode=True)
|
||
except Exception as _exc:
|
||
logger.debug(
|
||
"Failed to update cached agent tools after MCP reload: %s",
|
||
_exc,
|
||
)
|
||
|
||
# Inject a message at the END of the session history so the model knows tools changed
|
||
# next turn; appending after all existing messages preserves the prompt-cache prefix.
|
||
change_parts = []
|
||
if added:
|
||
change_parts.append(f"Added servers: {', '.join(sorted(added))}")
|
||
if removed:
|
||
change_parts.append(f"Removed servers: {', '.join(sorted(removed))}")
|
||
if reconnected:
|
||
change_parts.append(f"Reconnected servers: {', '.join(sorted(reconnected))}")
|
||
tool_summary = f"{len(new_tools)} MCP tool(s) now available" if new_tools else "No MCP tools available"
|
||
change_detail = ". ".join(change_parts) + ". " if change_parts else ""
|
||
reload_msg = {
|
||
"role": "user",
|
||
"content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
|
||
}
|
||
try:
|
||
session_entry = await self.async_session_store.get_or_create_session(event.source)
|
||
await self.async_session_store.append_to_transcript(
|
||
session_entry.session_id, reload_msg
|
||
)
|
||
except Exception:
|
||
pass # Best-effort; don't fail the reload over a transcript write
|
||
|
||
return "\n".join(lines)
|
||
|
||
except Exception as e:
|
||
logger.warning("MCP reload failed: %s", e)
|
||
return t("gateway.reload_mcp.failed", error=e)
|
||
|
||
def _get_proxy_url(self) -> Optional[str]:
|
||
"""Return the proxy URL if proxy mode is configured, else None.
|
||
|
||
GATEWAY_PROXY_URL env var (Docker-friendly) wins over ``gateway.proxy_url`` in config.yaml.
|
||
"""
|
||
from gateway.run import _load_gateway_config
|
||
url = os.getenv("GATEWAY_PROXY_URL", "").strip()
|
||
if url:
|
||
return url.rstrip("/")
|
||
cfg = _load_gateway_config()
|
||
url = (cfg.get("gateway") or {}).get("proxy_url")
|
||
url = (url or "").strip()
|
||
if url:
|
||
return url.rstrip("/")
|
||
return None
|
||
|
||
def _build_stream_consumer_config(
|
||
self,
|
||
source: "SessionSource",
|
||
scfg: Any,
|
||
adapter: Any,
|
||
*,
|
||
on_missing_cursor: str,
|
||
) -> "tuple[Any, Optional[Callable[[], None]]]":
|
||
"""Build the shared ``StreamConsumerConfig`` and optional Telegram pause-typing closure.
|
||
|
||
``on_missing_cursor`` handles adapters with ``SUPPORTS_MESSAGE_EDITING = False``:
|
||
``"fallback"`` (proxy path) streams with an empty cursor; ``"raise"`` (in-process path)
|
||
raises ``RuntimeError`` so the caller's ``except`` skips streaming entirely. Returns
|
||
``(consumer_cfg, pause_typing_before_finalize)``.
|
||
"""
|
||
from gateway.stream_consumer import StreamConsumerConfig
|
||
|
||
_pause_typing_before_finalize = None
|
||
if source.platform == Platform.TELEGRAM and hasattr(adapter, "pause_typing_for_chat"):
|
||
def _pause_typing_before_finalize(
|
||
_adapter=adapter,
|
||
_chat_id=source.chat_id,
|
||
) -> None:
|
||
_adapter.pause_typing_for_chat(_chat_id)
|
||
# Platforms that can't edit sent messages (e.g. QQ, WeChat) skip streaming entirely: the
|
||
# partial first message could never be updated, yielding duplicates (partial + final).
|
||
_adapter_supports_edit = getattr(adapter, "SUPPORTS_MESSAGE_EDITING", True)
|
||
# Adapters that can't edit but have a native-streaming transport (e.g. WeCom msgtype "stream"
|
||
# via send_stream_frame) pass the gate — the consumer's native branch delivers the full turn.
|
||
_adapter_supports_native_stream = bool(getattr(
|
||
adapter, "SUPPORTS_NATIVE_STREAMING", False,
|
||
))
|
||
if (
|
||
not _adapter_supports_edit
|
||
and not _adapter_supports_native_stream
|
||
and on_missing_cursor == "raise"
|
||
):
|
||
raise RuntimeError("skip streaming for non-editable platform")
|
||
_effective_cursor = scfg.cursor if _adapter_supports_edit else ""
|
||
# Some Matrix clients render the streaming cursor as a visible tofu/white-box artifact: keep
|
||
# streaming text on Matrix, but suppress the cursor.
|
||
_buffer_only = False
|
||
if source.platform == Platform.MATRIX:
|
||
_effective_cursor = ""
|
||
_buffer_only = True
|
||
# Fresh-final applies to Telegram only — other platforms edit in place cheaply (Discord, Slack)
|
||
# or lack the edit-timestamp-stays-stale problem.
|
||
_fresh_final_secs = (
|
||
float(getattr(scfg, "fresh_final_after_seconds", 0.0) or 0.0)
|
||
if source.platform == Platform.TELEGRAM
|
||
else 0.0
|
||
)
|
||
_consumer_cfg = StreamConsumerConfig(
|
||
edit_interval=scfg.edit_interval,
|
||
buffer_threshold=scfg.buffer_threshold,
|
||
cursor=_effective_cursor,
|
||
buffer_only=_buffer_only,
|
||
fresh_final_after_seconds=_fresh_final_secs,
|
||
transport=scfg.transport or "edit",
|
||
chat_type=getattr(source, "chat_type", "") or "",
|
||
)
|
||
return _consumer_cfg, _pause_typing_before_finalize
|
||
|
||
async def _run_agent_via_proxy(
|
||
self,
|
||
message: str,
|
||
context_prompt: str,
|
||
history: List[Dict[str, Any]],
|
||
source: "SessionSource",
|
||
session_id: str,
|
||
session_key: str = None,
|
||
run_generation: Optional[int] = None,
|
||
event_message_id: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Forward the message to a remote Hermes API server instead of running a local AIAgent.
|
||
|
||
This lets a Docker container handle Matrix E2EE while the actual agent runs on the host
|
||
with full access to local files, memory, skills, and a unified session store.
|
||
"""
|
||
from gateway.run import (
|
||
_GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS,
|
||
_load_gateway_config,
|
||
_platform_config_key,
|
||
)
|
||
try:
|
||
from aiohttp import ClientSession as _AioClientSession, ClientTimeout
|
||
except ImportError:
|
||
return {
|
||
"final_response": "⚠️ Proxy mode requires aiohttp. Install with: pip install aiohttp",
|
||
"messages": [],
|
||
"api_calls": 0,
|
||
"tools": [],
|
||
}
|
||
|
||
proxy_url = self._get_proxy_url()
|
||
if not proxy_url:
|
||
return {
|
||
"final_response": "⚠️ Proxy URL not configured (GATEWAY_PROXY_URL or gateway.proxy_url)",
|
||
"messages": [],
|
||
"api_calls": 0,
|
||
"tools": [],
|
||
}
|
||
|
||
# Scope-aware read: the proxy key is a per-profile credential; under multiplex honor the
|
||
# installed scope's verdict (Slack pattern for the unscoped default-profile loop).
|
||
try:
|
||
from agent.secret_scope import UnscopedSecretError, get_secret
|
||
|
||
try:
|
||
proxy_key = (get_secret("GATEWAY_PROXY_KEY") or "").strip()
|
||
except UnscopedSecretError:
|
||
proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip()
|
||
except Exception:
|
||
proxy_key = os.getenv("GATEWAY_PROXY_KEY", "").strip()
|
||
|
||
def _run_still_current() -> bool:
|
||
if run_generation is None or not session_key:
|
||
return True
|
||
return self._is_session_run_current(session_key, run_generation)
|
||
|
||
# Build messages in OpenAI chat format. The remote api_server keeps continuity via
|
||
# X-Hermes-Session-Id and loads its own history, so send only the current message; if the
|
||
# remote has no history yet, include a compact text-only local history (remote replays tools).
|
||
api_messages: List[Dict[str, str]] = []
|
||
|
||
if context_prompt:
|
||
api_messages.append({"role": "system", "content": context_prompt})
|
||
|
||
for msg in history:
|
||
role = msg.get("role")
|
||
content = msg.get("content")
|
||
if role in {"user", "assistant"} and content:
|
||
api_messages.append({"role": role, "content": content})
|
||
|
||
api_messages.append({"role": "user", "content": message})
|
||
|
||
# HTTP headers ---------------------------------------------------
|
||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||
if proxy_key:
|
||
headers["Authorization"] = f"Bearer {proxy_key}"
|
||
if session_id:
|
||
headers["X-Hermes-Session-Id"] = session_id
|
||
|
||
body = {
|
||
"model": "hermes-agent",
|
||
"messages": api_messages,
|
||
"stream": True,
|
||
}
|
||
|
||
# Set up platform streaming if available -------------------------
|
||
_stream_consumer = None
|
||
_scfg = getattr(getattr(self, "config", None), "streaming", None)
|
||
if _scfg is None:
|
||
from gateway.config import StreamingConfig
|
||
_scfg = StreamingConfig()
|
||
|
||
platform_key = _platform_config_key(source.platform)
|
||
user_config = _load_gateway_config()
|
||
from gateway.display_config import resolve_display_setting
|
||
_plat_streaming = resolve_display_setting(
|
||
user_config, platform_key, "streaming"
|
||
)
|
||
_streaming_enabled = (
|
||
_scfg.enabled and _scfg.transport != "off"
|
||
if _plat_streaming is None
|
||
else bool(_plat_streaming)
|
||
)
|
||
|
||
_thread_metadata: Optional[Dict[str, Any]] = self._thread_metadata_for_source(source, event_message_id)
|
||
|
||
if _streaming_enabled:
|
||
try:
|
||
from gateway.stream_consumer import GatewayStreamConsumer
|
||
_adapter = self._adapter_for_source(source)
|
||
if _adapter:
|
||
_consumer_cfg, _pause_typing_before_finalize = (
|
||
self._build_stream_consumer_config(
|
||
source, _scfg, _adapter,
|
||
on_missing_cursor="fallback",
|
||
)
|
||
)
|
||
_stream_consumer = GatewayStreamConsumer(
|
||
adapter=_adapter,
|
||
chat_id=source.chat_id,
|
||
config=_consumer_cfg,
|
||
metadata=_thread_metadata,
|
||
on_before_finalize=_pause_typing_before_finalize,
|
||
initial_reply_to_id=event_message_id,
|
||
run_still_current=_run_still_current,
|
||
)
|
||
except Exception as _sc_err:
|
||
logger.debug("Proxy: could not set up stream consumer: %s", _sc_err)
|
||
|
||
# Run the stream consumer task in the background
|
||
stream_task = None
|
||
if _stream_consumer:
|
||
stream_task = asyncio.create_task(_stream_consumer.run())
|
||
|
||
# Send typing indicator
|
||
_adapter = self._adapter_for_source(source)
|
||
if _adapter:
|
||
with suppress(Exception):
|
||
await _adapter.send_typing(source.chat_id, metadata=_thread_metadata)
|
||
|
||
# Make the HTTP request with SSE streaming -----------------------
|
||
full_response = ""
|
||
_start = time.time()
|
||
|
||
try:
|
||
_timeout = ClientTimeout(total=0, sock_read=1800)
|
||
async with _AioClientSession(timeout=_timeout) as session:
|
||
async with session.post(
|
||
f"{proxy_url}/v1/chat/completions",
|
||
json=body,
|
||
headers=headers,
|
||
) as resp:
|
||
if resp.status != 200:
|
||
error_text = await resp.text()
|
||
logger.warning(
|
||
"Proxy error (%d) from %s: %s",
|
||
resp.status, proxy_url, error_text[:500],
|
||
)
|
||
return {
|
||
"final_response": f"⚠️ Proxy error ({resp.status}): {error_text[:300]}",
|
||
"messages": [],
|
||
"api_calls": 0,
|
||
"tools": [],
|
||
}
|
||
|
||
# Parse SSE stream
|
||
buffer = ""
|
||
async for chunk in resp.content.iter_any():
|
||
if not _run_still_current():
|
||
logger.info(
|
||
"Discarding stale proxy stream for %s — generation %d is no longer current",
|
||
session_key or "?",
|
||
run_generation or 0,
|
||
)
|
||
return {
|
||
"final_response": "",
|
||
"messages": [],
|
||
"api_calls": 0,
|
||
"tools": [],
|
||
"history_offset": len(history),
|
||
"session_id": session_id,
|
||
"response_previewed": False,
|
||
}
|
||
text = chunk.decode("utf-8", errors="replace")
|
||
buffer += text
|
||
|
||
# Process complete SSE lines
|
||
while "\n" in buffer:
|
||
line, buffer = buffer.split("\n", 1)
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
if line.startswith("data: "):
|
||
data = line[6:]
|
||
if data.strip() == "[DONE]":
|
||
break
|
||
try:
|
||
obj = json.loads(data)
|
||
choices = obj.get("choices", [])
|
||
if choices:
|
||
delta = choices[0].get("delta", {})
|
||
content = delta.get("content", "")
|
||
if content:
|
||
full_response += content
|
||
if _stream_consumer:
|
||
_stream_consumer.on_delta(content)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
if len(buffer) > _GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS:
|
||
raise ValueError(
|
||
"Proxy SSE stream exceeded max buffer size without a line boundary"
|
||
)
|
||
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("Proxy connection error to %s: %s", proxy_url, e)
|
||
if not full_response:
|
||
return {
|
||
"final_response": f"⚠️ Proxy connection error: {e}",
|
||
"messages": [],
|
||
"api_calls": 0,
|
||
"tools": [],
|
||
}
|
||
# Partial response — return what we got
|
||
finally:
|
||
# Finalize stream consumer
|
||
if _stream_consumer:
|
||
_stream_consumer.finish()
|
||
if stream_task:
|
||
try:
|
||
await asyncio.wait_for(stream_task, timeout=5.0)
|
||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||
stream_task.cancel()
|
||
|
||
_elapsed = time.time() - _start
|
||
if not _run_still_current():
|
||
logger.info(
|
||
"Discarding stale proxy result for %s — generation %d is no longer current",
|
||
session_key or "?",
|
||
run_generation or 0,
|
||
)
|
||
return {
|
||
"final_response": "",
|
||
"messages": [],
|
||
"api_calls": 0,
|
||
"tools": [],
|
||
"history_offset": len(history),
|
||
"session_id": session_id,
|
||
"response_previewed": False,
|
||
}
|
||
logger.info(
|
||
"proxy response: url=%s session=%s time=%.1fs response=%d chars",
|
||
proxy_url, (session_id or "")[:20], _elapsed, len(full_response),
|
||
)
|
||
|
||
return {
|
||
"final_response": full_response or "(No response from remote agent)",
|
||
"messages": [
|
||
{"role": "user", "content": message},
|
||
{"role": "assistant", "content": full_response},
|
||
],
|
||
"api_calls": 1,
|
||
"tools": [],
|
||
"history_offset": len(history),
|
||
"session_id": session_id,
|
||
"response_previewed": _stream_consumer is not None and bool(full_response),
|
||
}
|
||
|
||
async def _run_agent(
|
||
self,
|
||
message: str,
|
||
context_prompt: str,
|
||
history: List[Dict[str, Any]],
|
||
source: SessionSource,
|
||
session_id: str,
|
||
session_key: str = None,
|
||
run_generation: Optional[int] = None,
|
||
_interrupt_depth: int = 0,
|
||
event_message_id: Optional[str] = None,
|
||
inbound_message_id: Optional[str] = None,
|
||
channel_prompt: Optional[str] = None,
|
||
moa_config: Optional[dict] = None,
|
||
persist_user_message: Optional[Any] = None,
|
||
persist_user_timestamp: Optional[float] = None,
|
||
persist_user_display_kind: Optional[str] = None,
|
||
message_type: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Profile-scoping wrapper around the agent run.
|
||
|
||
Under multiplexing, run the turn inside ``_profile_runtime_scope`` so config/skills/memory
|
||
resolve to the source profile's home AND credentials come from its secret scope (never
|
||
process-global ``os.environ``). Transparent pass-through when multiplexing is off.
|
||
"""
|
||
from gateway.run import _profile_runtime_scope
|
||
if not getattr(getattr(self, "config", None), "multiplex_profiles", False):
|
||
return await self._run_agent_inner(
|
||
message, context_prompt, history, source, session_id,
|
||
session_key=session_key, run_generation=run_generation,
|
||
_interrupt_depth=_interrupt_depth, event_message_id=event_message_id,
|
||
inbound_message_id=inbound_message_id,
|
||
channel_prompt=channel_prompt, moa_config=moa_config,
|
||
persist_user_message=persist_user_message,
|
||
persist_user_timestamp=persist_user_timestamp,
|
||
persist_user_display_kind=persist_user_display_kind,
|
||
message_type=message_type,
|
||
)
|
||
|
||
profile_home = self._resolve_profile_home_for_source(source)
|
||
with _profile_runtime_scope(profile_home):
|
||
return await self._run_agent_inner(
|
||
message, context_prompt, history, source, session_id,
|
||
session_key=session_key, run_generation=run_generation,
|
||
_interrupt_depth=_interrupt_depth, event_message_id=event_message_id,
|
||
inbound_message_id=inbound_message_id,
|
||
channel_prompt=channel_prompt, moa_config=moa_config,
|
||
persist_user_message=persist_user_message,
|
||
persist_user_timestamp=persist_user_timestamp,
|
||
persist_user_display_kind=persist_user_display_kind,
|
||
message_type=message_type,
|
||
)
|
||
|
||
def _run_agent_display_settings(self, source: SessionSource) -> "GatewayRunner._RunAgentDisplay":
|
||
"""Resolve per-platform display, progress, status and streaming-surface settings for a turn."""
|
||
from gateway.run import (
|
||
_gateway_platform_value,
|
||
_has_platform_display_override,
|
||
_load_gateway_config,
|
||
_platform_config_key,
|
||
)
|
||
user_config = _load_gateway_config()
|
||
platform_key = _platform_config_key(source.platform)
|
||
|
||
enabled_toolsets = self._resolve_enabled_toolsets_for_source(
|
||
user_config, source, platform_key
|
||
)
|
||
agent_cfg_local = user_config.get("agent") or {}
|
||
from agent.skill_utils import parse_config_string_list
|
||
|
||
disabled_toolsets = parse_config_string_list(agent_cfg_local.get("disabled_toolsets")) or None
|
||
|
||
display_config = user_config.get("display", {})
|
||
if not isinstance(display_config, dict):
|
||
display_config = {}
|
||
|
||
# Per-platform display settings via display_config: display.platforms.<platform>.<key>, then
|
||
# display.<key> global, then built-in platform defaults.
|
||
from gateway.display_config import resolve_display_setting
|
||
|
||
# Apply tool preview length config (0 = no limit)
|
||
try:
|
||
from agent.display import set_tool_preview_max_len
|
||
_tpl = resolve_display_setting(user_config, platform_key, "tool_preview_length", 0)
|
||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||
except Exception:
|
||
pass
|
||
|
||
# Apply friendly tool labels config (default on) — per-platform aware
|
||
try:
|
||
from agent.display import set_friendly_tool_labels
|
||
_ftl = resolve_display_setting(user_config, platform_key, "friendly_tool_labels", True)
|
||
set_friendly_tool_labels(bool(_ftl))
|
||
except Exception:
|
||
pass
|
||
|
||
# Tool progress mode — resolved per-platform with env var fallback
|
||
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
|
||
_env_tp = os.getenv("HERMES_TOOL_PROGRESS_MODE")
|
||
_display_cfg = display_config if isinstance(display_config, dict) else {}
|
||
_platforms_cfg = _display_cfg.get("platforms") or {}
|
||
_platform_cfg = _platforms_cfg.get(platform_key) or {}
|
||
_legacy_tp_overrides = _display_cfg.get("tool_progress_overrides") or {}
|
||
_tool_progress_configured = (
|
||
"tool_progress" in _display_cfg
|
||
or (
|
||
isinstance(_platform_cfg, dict)
|
||
and "tool_progress" in _platform_cfg
|
||
)
|
||
or (
|
||
isinstance(_legacy_tp_overrides, dict)
|
||
and platform_key in _legacy_tp_overrides
|
||
)
|
||
)
|
||
progress_mode = (
|
||
_env_tp
|
||
if _env_tp and not _tool_progress_configured
|
||
else (_resolved_tp or _env_tp or "all")
|
||
)
|
||
# Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool)
|
||
progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate"
|
||
from gateway.status_phrases import choose_status_phrase, resolve_status_phrase_catalog
|
||
_generic_status_recent: List[str] = []
|
||
_generic_status_catalog = resolve_status_phrase_catalog(user_config, platform_key)
|
||
|
||
def _display_surface_mode(
|
||
setting: str,
|
||
*,
|
||
default: bool = False,
|
||
require_platform_override_for: set[Any] | None = None,
|
||
allow_generic: bool = False,
|
||
) -> str:
|
||
"""Return off|raw|generic for a gateway visibility surface."""
|
||
if require_platform_override_for:
|
||
current_platform = _gateway_platform_value(source.platform)
|
||
platform_only = {
|
||
_gateway_platform_value(item)
|
||
for item in require_platform_override_for
|
||
}
|
||
if (
|
||
current_platform in platform_only
|
||
and not _has_platform_display_override(user_config, platform_key, setting)
|
||
):
|
||
return "off"
|
||
value = resolve_display_setting(user_config, platform_key, setting, default)
|
||
if isinstance(value, str) and value.strip().lower() == "generic":
|
||
return "generic" if allow_generic else "off"
|
||
return "raw" if bool(value) else "off"
|
||
|
||
def _generic_status_phrase(kind: str, *, tool_name: str | None = None, preview: str | None = None, args: Any = None) -> str:
|
||
try:
|
||
return choose_status_phrase(
|
||
kind,
|
||
tool_name=tool_name,
|
||
preview=preview,
|
||
args=args,
|
||
recent=_generic_status_recent,
|
||
catalog=_generic_status_catalog,
|
||
)
|
||
except Exception as _phrase_err:
|
||
logger.debug("generic status phrase selection failed: %s", _phrase_err)
|
||
return "still on it" if kind in {"heartbeat", "waiting", "long_running", "status"} else "one sec"
|
||
# Disable tool progress for webhooks - they don't support message editing,
|
||
# so each progress line would be sent as a separate message.
|
||
from gateway.config import Platform
|
||
tool_progress_enabled = progress_mode not in {"off", "log"} and source.platform != Platform.WEBHOOK
|
||
# Live working-state status for text-rendering typing indicators (Slack's assistant status
|
||
# line). Independent of tool_progress (Slack defaults it off; the status line is ephemeral).
|
||
# Rides the existing _keep_typing refresh — the callback only stores a phrase, no extra calls.
|
||
_live_status_mode = resolve_display_setting(
|
||
user_config, platform_key, "live_status", "full"
|
||
)
|
||
_live_status_adapter = self._adapter_for_source(source)
|
||
if not getattr(_live_status_adapter, "supports_status_text", False):
|
||
_live_status_adapter = None
|
||
if _live_status_mode == "off":
|
||
_live_status_adapter = None
|
||
# "log" mode: tool calls are written to ~/.hermes/logs/tool_calls.log
|
||
# instead of the chat (#3459 / #3458). Gateway-only by design.
|
||
log_mode_enabled = progress_mode == "log" and source.platform != Platform.WEBHOOK
|
||
log_queue: "queue.Queue | None" = queue.Queue() if log_mode_enabled else None
|
||
# Natural assistant status messages are independent from tool progress and token streaming:
|
||
# tool_progress can stay quiet while users opt into concise mid-turn updates.
|
||
interim_assistant_messages_mode = _display_surface_mode(
|
||
"interim_assistant_messages",
|
||
default=True,
|
||
require_platform_override_for={Platform.MATTERMOST},
|
||
)
|
||
interim_assistant_messages_enabled = (
|
||
source.platform != Platform.WEBHOOK
|
||
and interim_assistant_messages_mode != "off"
|
||
)
|
||
# thinking_progress is independent — if enabled, we need the progress queue even when
|
||
# tool_progress is off (thinking relay uses same infra). Mattermost requires a per-platform
|
||
# opt-in: global scratch-text display is too easy to leak into busy public threads.
|
||
_thinking_mode = _display_surface_mode(
|
||
"thinking_progress",
|
||
default=False,
|
||
require_platform_override_for={Platform.MATTERMOST},
|
||
)
|
||
_thinking_enabled = _thinking_mode != "off"
|
||
# Slack-native task cards: with the Slack adapter's opt-in, tool progress renders as native
|
||
# plan/task cards via chat.startStream, so the progress queue is needed even though Slack keeps
|
||
# text tool_progress off by default (requiring both flags would silently disable the feature).
|
||
_progress_adapter_for_native = self._adapter_for_source(source)
|
||
_native_slack_task_cards = False
|
||
if (
|
||
source.platform == Platform.SLACK
|
||
and _progress_adapter_for_native is not None
|
||
and hasattr(_progress_adapter_for_native, "native_task_cards_enabled")
|
||
):
|
||
try:
|
||
_native_slack_task_cards = bool(
|
||
_progress_adapter_for_native.native_task_cards_enabled()
|
||
)
|
||
except Exception:
|
||
logger.debug("Slack native task-card config check failed", exc_info=True)
|
||
needs_progress_queue = (
|
||
tool_progress_enabled or _thinking_enabled or _native_slack_task_cards
|
||
)
|
||
return self._RunAgentDisplay(
|
||
user_config=user_config,
|
||
platform_key=platform_key,
|
||
enabled_toolsets=enabled_toolsets,
|
||
disabled_toolsets=disabled_toolsets,
|
||
resolve_display_setting=resolve_display_setting,
|
||
progress_mode=progress_mode,
|
||
progress_grouping=progress_grouping,
|
||
_display_surface_mode=_display_surface_mode,
|
||
tool_progress_enabled=tool_progress_enabled,
|
||
_live_status_mode=_live_status_mode,
|
||
_live_status_adapter=_live_status_adapter,
|
||
log_mode_enabled=log_mode_enabled,
|
||
log_queue=log_queue,
|
||
interim_assistant_messages_enabled=interim_assistant_messages_enabled,
|
||
_thinking_enabled=_thinking_enabled,
|
||
_native_slack_task_cards=_native_slack_task_cards,
|
||
needs_progress_queue=needs_progress_queue,
|
||
_generic_status_phrase=_generic_status_phrase,
|
||
)
|
||
|
||
def _run_agent_build_turn_context(
|
||
self,
|
||
disp: "GatewayRunner._RunAgentDisplay",
|
||
AIAgent: Any,
|
||
*,
|
||
message: str,
|
||
context_prompt: str,
|
||
history: List[Dict[str, Any]],
|
||
source: SessionSource,
|
||
session_id: str,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
_interrupt_depth: int,
|
||
event_message_id: Optional[str],
|
||
inbound_message_id: Optional[str],
|
||
channel_prompt: Optional[str],
|
||
moa_config: Optional[dict],
|
||
persist_user_message: Optional[Any],
|
||
persist_user_timestamp: Optional[float],
|
||
persist_user_display_kind: Optional[str],
|
||
) -> Tuple[TurnContext, TurnRunner, Any]:
|
||
"""Build the progress queues / holders, the ``TurnContext`` and its ``TurnRunner``.
|
||
|
||
Returns ``(turn_ctx, turn_runner, cleanup_adapter)``; the progress-bubble cleanup flags travel
|
||
on ``turn_ctx._cleanup_progress`` / ``turn_ctx._cleanup_msg_ids``.
|
||
"""
|
||
from gateway.run import TurnRunner
|
||
def _run_still_current() -> bool:
|
||
if run_generation is None or not session_key:
|
||
return True
|
||
return self._is_session_run_current(session_key, run_generation)
|
||
|
||
# Queue for progress messages (thread-safe)
|
||
progress_queue = queue.Queue() if disp.needs_progress_queue else None
|
||
last_tool = [None] # Mutable container for tracking in closure
|
||
last_progress_msg = [None] # Track last message for dedup
|
||
repeat_count = [0] # How many times the same message repeated
|
||
# True when the previous progress line was a terminal fenced code block — consecutive terminal
|
||
# calls then drop the repeated "💻 terminal" header and render back-to-back blocks.
|
||
last_was_terminal_block = [False]
|
||
|
||
# Discord voice "verbal ack before tool calls": with the continuous mixer installed
|
||
# (discord.voice_fx.enabled), speak a short phrase over the idle bed on the FIRST tool call of
|
||
# the turn (from tool_start_callback, independent of the tool-progress text gate); once per turn.
|
||
_voice_ack_fired = [False]
|
||
_voice_ack_guild: List[Optional[int]] = [None]
|
||
if source.platform == Platform.DISCORD:
|
||
_va = self.adapters.get(Platform.DISCORD)
|
||
# source.chat_id is the linked text channel; resolve the guild whose
|
||
# voice connection is bound to it (mirrors DiscordAdapter.play_tts).
|
||
_vtc = getattr(_va, "_voice_text_channels", None)
|
||
if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"):
|
||
for _gid, _tc in _vtc.items():
|
||
if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid):
|
||
_voice_ack_guild[0] = _gid
|
||
break
|
||
_voice_ack_loop = asyncio.get_running_loop()
|
||
|
||
# voice_ack_callback extracted to TurnRunner.voice_ack_callback
|
||
# (published onto turn_ctx after the runner is constructed below).
|
||
|
||
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter that implements
|
||
# ``delete_message``). Failed runs skip cleanup so the bubbles remain as breadcrumbs.
|
||
_cleanup_progress = bool(
|
||
disp.resolve_display_setting(disp.user_config, disp.platform_key, "cleanup_progress")
|
||
)
|
||
_cleanup_adapter = self._adapter_for_source(source) if _cleanup_progress else None
|
||
# getattr, not attribute access — same duck-typed-adapter guard as the edit_message check in
|
||
# send_progress_messages: a fake adapter without delete_message means "can't delete", not a crash.
|
||
_cleanup_delete = getattr(type(_cleanup_adapter), "delete_message", None) if _cleanup_adapter is not None else None
|
||
if _cleanup_adapter is not None and (
|
||
_cleanup_delete is None
|
||
or _cleanup_delete is BasePlatformAdapter.delete_message
|
||
):
|
||
# Adapter doesn't support deletion — silently disable.
|
||
_cleanup_progress = False
|
||
_cleanup_adapter = None
|
||
_cleanup_msg_ids: List[str] = []
|
||
# First-touch onboarding latch: fires at most once per run, even if
|
||
# several tools exceed the threshold.
|
||
long_tool_hint_fired = [False]
|
||
_LONG_TOOL_THRESHOLD_S = 30.0
|
||
|
||
turn_ctx = TurnContext(
|
||
source=source,
|
||
_run_still_current=_run_still_current,
|
||
_live_status_adapter=disp._live_status_adapter,
|
||
_live_status_mode=disp._live_status_mode,
|
||
_thinking_enabled=disp._thinking_enabled,
|
||
progress_mode=disp.progress_mode,
|
||
progress_grouping=disp.progress_grouping,
|
||
tool_progress_enabled=disp.tool_progress_enabled,
|
||
progress_queue=progress_queue,
|
||
log_queue=disp.log_queue,
|
||
last_progress_msg=last_progress_msg,
|
||
last_tool=last_tool,
|
||
last_was_terminal_block=last_was_terminal_block,
|
||
repeat_count=repeat_count,
|
||
long_tool_hint_fired=long_tool_hint_fired,
|
||
_LONG_TOOL_THRESHOLD_S=_LONG_TOOL_THRESHOLD_S,
|
||
_cleanup_progress=_cleanup_progress,
|
||
_cleanup_msg_ids=_cleanup_msg_ids,
|
||
message=message,
|
||
AIAgent=AIAgent,
|
||
resolve_display_setting=disp.resolve_display_setting,
|
||
user_config=disp.user_config,
|
||
enabled_toolsets=disp.enabled_toolsets,
|
||
disabled_toolsets=disp.disabled_toolsets,
|
||
log_mode_enabled=disp.log_mode_enabled,
|
||
interim_assistant_messages_enabled=disp.interim_assistant_messages_enabled,
|
||
needs_progress_queue=disp.needs_progress_queue,
|
||
_native_slack_task_cards=disp._native_slack_task_cards,
|
||
_voice_ack_fired=_voice_ack_fired,
|
||
_voice_ack_guild=_voice_ack_guild,
|
||
_voice_ack_loop=_voice_ack_loop,
|
||
history=history,
|
||
context_prompt=context_prompt,
|
||
channel_prompt=channel_prompt,
|
||
session_id=session_id,
|
||
session_key=session_key,
|
||
run_generation=run_generation,
|
||
_interrupt_depth=_interrupt_depth,
|
||
event_message_id=event_message_id,
|
||
inbound_message_id=inbound_message_id,
|
||
moa_config=moa_config,
|
||
persist_user_message=persist_user_message,
|
||
persist_user_timestamp=persist_user_timestamp,
|
||
persist_user_display_kind=persist_user_display_kind,
|
||
)
|
||
turn_runner = TurnRunner(self, turn_ctx)
|
||
# Callback invoked by agent on tool lifecycle events — extracted to
|
||
# TurnRunner.progress_callback (bound method, same signature).
|
||
turn_ctx.progress_callback = turn_runner.progress_callback
|
||
turn_ctx.voice_ack_callback = turn_runner.voice_ack_callback
|
||
turn_ctx.native_tool_start_callback = turn_runner.combined_tool_start_callback
|
||
turn_ctx.native_tool_complete_callback = (
|
||
turn_runner.native_tool_complete_callback
|
||
)
|
||
return turn_ctx, turn_runner, _cleanup_adapter
|
||
|
||
def _run_agent_progress_threading(
|
||
self,
|
||
source: SessionSource,
|
||
event_message_id: Optional[str],
|
||
_native_slack_task_cards: bool,
|
||
) -> Tuple[Optional[dict], Optional[str], Any, Optional[str]]:
|
||
"""Resolve where progress bubbles are threaded.
|
||
|
||
Returns ``(progress_metadata, progress_reply_to, progress_thread_id, relay_prospective_thread_id)``.
|
||
"""
|
||
from gateway.run import _non_conversational_metadata, _resolve_progress_thread_id
|
||
# Background task accumulating tool lines into one edited progress message. Threading metadata
|
||
# is platform-specific: Slack DM threading needs the event_message_id fallback; Telegram forum
|
||
# topics use message_thread_id and Hermes-created private DM topic lanes need thread metadata
|
||
# plus a reply anchor; Feishu only honors reply_in_thread on a reply, so topic progress replies
|
||
# to the triggering event; others use explicit source.thread_id only. Slack honours
|
||
# reply_in_thread=false: don't synthesise a thread for progress, or every later reply inherits it.
|
||
_progress_reply_in_thread = True
|
||
if source.platform == Platform.SLACK:
|
||
_slack_adapter_for_progress = self._adapter_for_source(source)
|
||
if _slack_adapter_for_progress is not None:
|
||
try:
|
||
# Relay lane: adapter owns mode resolution (nested platforms.relay.extra.slack subset,
|
||
# flat-key fallback). Native lane: read the flat extra as before.
|
||
_mode_fn = getattr(
|
||
_slack_adapter_for_progress,
|
||
"_effective_reply_in_thread",
|
||
None,
|
||
)
|
||
if callable(_mode_fn):
|
||
_progress_reply_in_thread = bool(_mode_fn())
|
||
else:
|
||
_progress_reply_in_thread = bool(
|
||
_slack_adapter_for_progress.config.extra.get(
|
||
"reply_in_thread", True
|
||
)
|
||
)
|
||
except Exception:
|
||
_progress_reply_in_thread = True
|
||
elif str(getattr(source.platform, "value", source.platform) or "").lower() == "buzz":
|
||
# Buzz honours the same opt-out (reply_to_mode: off / extra.reply_in_thread: false): when the
|
||
# user asked for flat channel replies, progress must not synthesise a thread either.
|
||
_buzz_adapter_for_progress = self._adapter_for_source(source)
|
||
if _buzz_adapter_for_progress is not None:
|
||
try:
|
||
_progress_reply_in_thread = (
|
||
getattr(_buzz_adapter_for_progress, "_reply_to_mode", "first")
|
||
!= "off"
|
||
)
|
||
except Exception:
|
||
_progress_reply_in_thread = True
|
||
_progress_thread_id = _resolve_progress_thread_id(
|
||
source.platform, source.thread_id, event_message_id,
|
||
reply_in_thread=_progress_reply_in_thread,
|
||
)
|
||
# Relay Discord auto-thread lane: a channel-initiating message has no thread_id at ingest
|
||
# (thread is born on the connector's FIRST send). The connector stamps prospective_thread_id
|
||
# (anchor id == the thread it will create); carry it as reply_to on the progress send so
|
||
# bubbles route into the SAME auto-thread instead of landing flat in the parent channel.
|
||
_relay_prospective_thread_id = (
|
||
str(getattr(source, "prospective_thread_id", None))
|
||
if source.platform == Platform.DISCORD
|
||
and getattr(source, "delivered_via_upstream_relay", False)
|
||
and getattr(source, "prospective_thread_id", None)
|
||
and not source.thread_id
|
||
else None
|
||
)
|
||
_progress_metadata = (
|
||
self._thread_metadata_for_source(source, event_message_id)
|
||
if _progress_thread_id == source.thread_id
|
||
else self._thread_metadata_for_target(
|
||
source.platform,
|
||
source.chat_id,
|
||
_progress_thread_id,
|
||
chat_type=getattr(source, "chat_type", None),
|
||
reply_to_message_id=event_message_id,
|
||
)
|
||
) if _progress_thread_id else None
|
||
if _progress_metadata is None and _relay_prospective_thread_id:
|
||
# No real thread yet, but the connector will auto-thread on the
|
||
# reply anchor; carry it so progress joins that thread.
|
||
_progress_metadata = {"reply_to_message_id": event_message_id}
|
||
_progress_metadata = _non_conversational_metadata(_progress_metadata, platform=source.platform)
|
||
if _native_slack_task_cards:
|
||
# chat.startStream in channels requires the recipient team/user
|
||
# pair; harmless extras elsewhere, so stamp them whenever known.
|
||
_progress_metadata = dict(_progress_metadata or {})
|
||
if source.scope_id:
|
||
_progress_metadata.setdefault("recipient_team_id", source.scope_id)
|
||
_progress_metadata.setdefault("slack_team_id", source.scope_id)
|
||
if source.user_id:
|
||
_progress_metadata.setdefault("recipient_user_id", source.user_id)
|
||
_progress_reply_to = (
|
||
event_message_id
|
||
if (
|
||
source.platform in (Platform.FEISHU, Platform.MATTERMOST)
|
||
and source.thread_id
|
||
and event_message_id
|
||
)
|
||
or (
|
||
# Buzz has no native thread_id; threading is always via reply-to the triggering event id
|
||
# (channel clutter otherwise); skipped when the user opted out of threaded replies.
|
||
str(getattr(source.platform, "value", source.platform) or "").lower() == "buzz"
|
||
and event_message_id
|
||
and _progress_reply_in_thread
|
||
)
|
||
or _relay_prospective_thread_id
|
||
else None
|
||
)
|
||
return _progress_metadata, _progress_reply_to, _progress_thread_id, _relay_prospective_thread_id
|
||
|
||
async def _run_agent_write_tool_log(self, log_queue: Any) -> None:
|
||
"""Drain log_queue and append tool-call lines to tool_calls.log (tool_progress=log).
|
||
|
||
RotatingFileHandler (5MB × 3) bounds the log; RedactingFormatter keeps secrets off disk.
|
||
"""
|
||
from gateway.run import _hermes_home
|
||
if log_queue is None:
|
||
return
|
||
from logging.handlers import RotatingFileHandler
|
||
|
||
from agent.redact import RedactingFormatter
|
||
|
||
log_dir = _hermes_home / "logs"
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
file_handler = RotatingFileHandler(
|
||
log_dir / "tool_calls.log",
|
||
maxBytes=5 * 1024 * 1024,
|
||
backupCount=3,
|
||
encoding="utf-8",
|
||
)
|
||
file_handler.setFormatter(RedactingFormatter("%(message)s"))
|
||
tool_logger = logging.getLogger(f"hermes.tool_calls.{id(log_queue)}")
|
||
tool_logger.setLevel(logging.INFO)
|
||
tool_logger.propagate = False
|
||
tool_logger.addHandler(file_handler)
|
||
try:
|
||
while True:
|
||
try:
|
||
tool_logger.info("%s", log_queue.get_nowait())
|
||
except queue.Empty:
|
||
await asyncio.sleep(0.3)
|
||
except Exception as e:
|
||
logger.error("write_tool_log error: %s", e)
|
||
await asyncio.sleep(1)
|
||
except asyncio.CancelledError:
|
||
pass
|
||
finally:
|
||
# Drain remaining entries before closing so late tool calls
|
||
# from the final iteration aren't lost.
|
||
while True:
|
||
try:
|
||
tool_logger.info("%s", log_queue.get_nowait())
|
||
except queue.Empty:
|
||
break
|
||
except Exception:
|
||
break
|
||
tool_logger.removeHandler(file_handler)
|
||
try:
|
||
file_handler.flush()
|
||
file_handler.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _run_agent_status_thread_metadata(
|
||
self,
|
||
source: SessionSource,
|
||
event_message_id: Optional[str],
|
||
_progress_thread_id: Any,
|
||
_relay_prospective_thread_id: Optional[str],
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Thread metadata for status / approval / stream sends (Feishu carries the reply anchor)."""
|
||
if source.platform == Platform.FEISHU and source.thread_id and event_message_id:
|
||
# Feishu topics only keep messages inside the topic when they are sent via the reply API
|
||
# with reply_in_thread=true. Status/approval/stream paths usually only get metadata, so
|
||
# carry the triggering message id as a Feishu-specific fallback.
|
||
_status_thread_metadata: Optional[Dict[str, Any]] = {
|
||
"thread_id": _progress_thread_id,
|
||
"reply_to_message_id": event_message_id,
|
||
}
|
||
else:
|
||
_status_thread_metadata = (
|
||
self._thread_metadata_for_source(source, event_message_id)
|
||
if _progress_thread_id == source.thread_id
|
||
else self._thread_metadata_for_target(
|
||
source.platform,
|
||
source.chat_id,
|
||
_progress_thread_id,
|
||
chat_type=getattr(source, "chat_type", None),
|
||
reply_to_message_id=event_message_id,
|
||
)
|
||
) if _progress_thread_id else None
|
||
if _status_thread_metadata is None and _relay_prospective_thread_id:
|
||
# Relay Discord auto-thread lane (see _progress_metadata): carry the reply anchor so
|
||
# status/interim bubbles route into the same connector-created thread as the final reply.
|
||
_status_thread_metadata = {
|
||
"reply_to_message_id": event_message_id
|
||
}
|
||
return _status_thread_metadata
|
||
|
||
def _run_agent_start_streaming_tts(
|
||
self,
|
||
source: SessionSource,
|
||
message_type: Optional[str],
|
||
_status_thread_metadata: Optional[Dict[str, Any]],
|
||
streaming_tts_consumer_holder: list,
|
||
) -> None:
|
||
# Streaming TTS consumer setup. Created on the gateway event-loop thread (here), NOT inside
|
||
# run_sync's executor worker: the outer interrupt / finalisation paths reference the consumer
|
||
# via ``streaming_tts_consumer_holder[0]`` and would hit a cross-scope NameError.
|
||
_stts_adapter = self._adapter_for_source(source)
|
||
_is_voice_input = (
|
||
message_type is not None
|
||
and str(getattr(message_type, "value", message_type)).lower() == "voice"
|
||
)
|
||
if (
|
||
_stts_adapter is not None
|
||
and _is_voice_input
|
||
and _stts_adapter._should_auto_tts_for_chat(source.chat_id)
|
||
):
|
||
try:
|
||
from gateway.streaming_tts_consumer import StreamingTTSConsumer
|
||
from tools.tts_tool import _load_tts_config
|
||
_tts_cfg = _load_tts_config()
|
||
_gateway_loop = self._gateway_loop or asyncio.get_event_loop()
|
||
_stts_consumer = StreamingTTSConsumer(
|
||
adapter=_stts_adapter,
|
||
chat_id=source.chat_id,
|
||
tts_config=_tts_cfg,
|
||
loop=_gateway_loop,
|
||
metadata=_status_thread_metadata,
|
||
)
|
||
if _stts_consumer.active:
|
||
streaming_tts_consumer_holder[0] = _stts_consumer
|
||
_stts_consumer.start()
|
||
# else: consumer inactive (no streaming provider) — leave
|
||
# the holder as None so the whole-file fallback path runs.
|
||
except Exception as _stts_err:
|
||
logger.debug("Could not set up streaming TTS consumer: %s", _stts_err)
|
||
|
||
async def _run_agent_stream_consumer_task(self, stream_consumer_holder: list) -> None:
|
||
"""Wait for the stream consumer to be created, then run it."""
|
||
for _ in range(200): # Up to 10s wait
|
||
if stream_consumer_holder[0] is not None:
|
||
await stream_consumer_holder[0].run()
|
||
return
|
||
await asyncio.sleep(0.05)
|
||
|
||
async def _run_agent_track_agent(
|
||
self,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
agent_holder: list,
|
||
) -> None:
|
||
"""Track this agent as running for the session (interrupt support) once it is created."""
|
||
# Wait for agent to be created
|
||
while agent_holder[0] is None:
|
||
await asyncio.sleep(0.05)
|
||
if not session_key:
|
||
return
|
||
# Only promote the sentinel to the real agent if this run is still current. If /stop or
|
||
# /new bumped the generation while we were spinning up, leave the newer run's slot alone
|
||
# — we'll be discarded by the stale-result check in _handle_message_with_agent.
|
||
if run_generation is not None and not self._is_session_run_current(
|
||
session_key, run_generation
|
||
):
|
||
logger.info(
|
||
"Skipping stale agent promotion for %s — generation %s is no longer current",
|
||
session_key or "",
|
||
run_generation,
|
||
)
|
||
return
|
||
self._session_state(session_key).turn.agent = agent_holder[0]
|
||
if self._draining:
|
||
self._update_runtime_status("draining")
|
||
|
||
async def _run_agent_monitor_for_interrupt(
|
||
self,
|
||
source: SessionSource,
|
||
session_key: Optional[str],
|
||
agent_holder: list,
|
||
_interrupt_detected: "asyncio.Event",
|
||
streaming_tts_consumer_holder: list,
|
||
) -> None:
|
||
# Monitor adapter interrupts (new messages). PRIMARY interrupt path for regular text: Level 1
|
||
# (base.py) catches them before _handle_message(), so the Level 2 running_agent.interrupt() path
|
||
# never fires. The inactivity poll loop has a BACKUP check in case this task dies silently.
|
||
from gateway.run import _build_media_placeholder
|
||
if not session_key:
|
||
return
|
||
|
||
while True:
|
||
await asyncio.sleep(0.2) # Check every 200ms
|
||
try:
|
||
# Re-resolve adapter each iteration so reconnects don't
|
||
# leave us holding a stale reference.
|
||
_adapter = self._adapter_for_source(source)
|
||
if not _adapter:
|
||
continue
|
||
# Must use session_key (build_session_key output), NOT source.chat_id: the adapter
|
||
# stores interrupt events under the full session key.
|
||
if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key):
|
||
agent = agent_holder[0]
|
||
if agent:
|
||
# Peek WITHOUT consuming: the message must stay in _pending_messages for the
|
||
# post-run _dequeue_pending_event() (full MessageEvent + media). Popping here
|
||
# races: the agent may finish before checking _interrupt_requested, losing it.
|
||
_peek_event = _adapter._pending_messages.get(session_key)
|
||
pending_text = None
|
||
if _peek_event is not None:
|
||
pending_text = _peek_event.text or ""
|
||
# Transcribe audio BEFORE signaling the agent, so voice messages interrupt
|
||
# with the real transcript, not an empty string / file-path placeholder.
|
||
_media_urls = getattr(_peek_event, "media_urls", None) or []
|
||
if self._pending_event_audio_paths(_peek_event):
|
||
pending_text, _ = await self._transcribe_and_echo_pending_voice(
|
||
_peek_event,
|
||
_adapter,
|
||
source,
|
||
pending_text,
|
||
log_context="Voice-interrupt",
|
||
metadata={"thread_id": source.thread_id} if source.thread_id else None,
|
||
)
|
||
elif not pending_text and _media_urls:
|
||
pending_text = _build_media_placeholder(_peek_event)
|
||
logger.debug("Interrupt detected from adapter, signaling agent...")
|
||
agent.interrupt(pending_text)
|
||
_interrupt_detected.set()
|
||
# Abort streaming TTS on barge-in (#60671).
|
||
_stts = streaming_tts_consumer_holder[0]
|
||
if _stts is not None:
|
||
_stts.abort("barge-in")
|
||
break
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as _mon_err:
|
||
logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err)
|
||
|
||
@staticmethod
|
||
def _run_agent_stream_confirmed_final_delivery(
|
||
consumer,
|
||
final_text: str,
|
||
*,
|
||
previewed: bool = False,
|
||
) -> bool:
|
||
"""Return True only when the actual final reply reached the user."""
|
||
if consumer is None:
|
||
return False
|
||
if getattr(consumer, "final_response_sent", False):
|
||
# A successful finalize call is not proof the *content* was final: the edit may carry
|
||
# only the last preview snapshot. Reconcile against the recorded turn-final payload:
|
||
# only a demonstrable mismatch (False, incl. payload-less split delivery) overrides
|
||
# the flag; None keeps legacy trust so timeout dedup isn't regressed.
|
||
matcher = getattr(consumer, "delivered_final_matches", None)
|
||
if callable(matcher):
|
||
try:
|
||
if matcher(final_text) is False:
|
||
return False
|
||
except Exception:
|
||
pass
|
||
return True
|
||
if previewed:
|
||
has_delivered_text = getattr(consumer, "has_delivered_text", None)
|
||
if callable(has_delivered_text):
|
||
try:
|
||
return bool(has_delivered_text(final_text))
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
def _run_agent_start_turn_worker(
|
||
self,
|
||
turn_ctx: TurnContext,
|
||
run_sync: Callable[[], Any],
|
||
agent_holder: list,
|
||
session_id: str,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
) -> "GatewayRunner._RunAgentWorker":
|
||
"""Schedule ``run_sync`` on the executor plus the inactivity watchdog thread."""
|
||
from gateway.run import _float_env, _watch_gateway_turn_inactivity
|
||
# Thread pool so we don't block. *Inactivity* timeout, not wall-clock: the agent may run for
|
||
# hours while actively calling tools / streaming, but a hung API call or stuck tool is killed.
|
||
# agent.gateway_timeout / HERMES_AGENT_TIMEOUT (env wins); default 1800s; 0 = unlimited.
|
||
_agent_timeout_raw = _float_env("HERMES_AGENT_TIMEOUT", 1800)
|
||
_agent_timeout = _agent_timeout_raw if _agent_timeout_raw > 0 else None
|
||
_agent_warning_raw = _float_env("HERMES_AGENT_TIMEOUT_WARNING", 900)
|
||
_agent_warning = _agent_warning_raw if _agent_warning_raw > 0 else None
|
||
|
||
# A background=true process intentionally survives a successful turn, so capture
|
||
# existing IDs and reap only children created by THIS turn if it times out. The daemon
|
||
# watchdog is independent of asyncio: cgroup memory reclaim can starve the loop that
|
||
# runs the normal timeout poll, and cleanup must not wait for the loop to recover.
|
||
from tools.process_registry import process_registry
|
||
|
||
_turn_task_id = session_id or ""
|
||
_turn_process_baseline = process_registry.snapshot_running_ids(_turn_task_id)
|
||
turn_ctx.process_task_id = _turn_task_id
|
||
turn_ctx.process_baseline = _turn_process_baseline
|
||
_turn_worker_done = threading.Event()
|
||
_turn_timeout_fired = threading.Event()
|
||
_turn_cleanup_lock = threading.Lock()
|
||
# task_id is session-scoped, not turn-scoped: gate the eventual reap on this exact claim still
|
||
# being current, so a replacement turn on the same session that starts before the watchdog
|
||
# fires doesn't get its own fresh process killed by this turn's stale baseline.
|
||
_turn_run_generation = run_generation
|
||
_turn_is_current = (
|
||
(lambda: self._is_session_run_current(session_key, _turn_run_generation))
|
||
if _turn_run_generation is not None
|
||
else (lambda: True)
|
||
)
|
||
|
||
def _run_sync_with_timeout_lifecycle():
|
||
try:
|
||
return run_sync()
|
||
finally:
|
||
_turn_worker_done.set()
|
||
# `.turn.agent` is only reset to _AGENT_PENDING_SENTINEL when the *next* turn is
|
||
# claimed, so this agent stays reachable from _interrupt_and_clear_session()
|
||
# until then. Clearing ownership markers the instant our worker finishes means a
|
||
# /stop on the finished turn no longer reaps background work it left running.
|
||
_finished_agent = agent_holder[0] if agent_holder else None
|
||
if _finished_agent is not None:
|
||
_finished_agent._gateway_turn_process_task_id = ""
|
||
_finished_agent._gateway_turn_process_baseline = frozenset()
|
||
|
||
if _agent_timeout is not None:
|
||
threading.Thread(
|
||
target=_watch_gateway_turn_inactivity,
|
||
kwargs={
|
||
"agent_holder": agent_holder,
|
||
"task_id": _turn_task_id,
|
||
"process_baseline": _turn_process_baseline,
|
||
"timeout": _agent_timeout,
|
||
"worker_done": _turn_worker_done,
|
||
"timeout_fired": _turn_timeout_fired,
|
||
"cleanup_lock": _turn_cleanup_lock,
|
||
"poll_interval": 5.0,
|
||
"is_still_current": _turn_is_current,
|
||
},
|
||
name=f"gateway-turn-watchdog-{_turn_task_id[:12]}",
|
||
daemon=True,
|
||
).start()
|
||
_executor_task = asyncio.ensure_future(
|
||
self._run_in_executor_with_context(_run_sync_with_timeout_lifecycle)
|
||
)
|
||
return self._RunAgentWorker(
|
||
executor_task=_executor_task,
|
||
agent_timeout=_agent_timeout,
|
||
agent_warning=_agent_warning,
|
||
task_id=_turn_task_id,
|
||
process_baseline=_turn_process_baseline,
|
||
worker_done=_turn_worker_done,
|
||
timeout_fired=_turn_timeout_fired,
|
||
cleanup_lock=_turn_cleanup_lock,
|
||
is_current=_turn_is_current,
|
||
)
|
||
|
||
async def _run_agent_backup_interrupt_check(
|
||
self,
|
||
source: SessionSource,
|
||
session_key: Optional[str],
|
||
agent_holder: list,
|
||
_interrupt_detected: "asyncio.Event",
|
||
interrupt_monitor: "asyncio.Task",
|
||
streaming_tts_consumer_holder: list,
|
||
) -> None:
|
||
"""Backup interrupt check: if the monitor task died or missed the interrupt, catch it here."""
|
||
from gateway.run import _build_media_placeholder
|
||
if not _interrupt_detected.is_set() and session_key:
|
||
_backup_adapter = self._adapter_for_source(source)
|
||
_backup_agent = agent_holder[0]
|
||
if (_backup_adapter and _backup_agent
|
||
and hasattr(_backup_adapter, 'has_pending_interrupt')
|
||
and _backup_adapter.has_pending_interrupt(session_key)):
|
||
_bp_event = _backup_adapter._pending_messages.get(session_key)
|
||
_bp_text = _bp_event.text if _bp_event else None
|
||
if _bp_event is not None:
|
||
_bp_media_urls = getattr(_bp_event, "media_urls", None) or []
|
||
if self._pending_event_audio_paths(_bp_event):
|
||
_bp_text, _ = await self._transcribe_and_echo_pending_voice(
|
||
_bp_event,
|
||
_backup_adapter,
|
||
source,
|
||
_bp_text or "",
|
||
log_context="Voice-backup-interrupt",
|
||
metadata={"thread_id": source.thread_id} if source.thread_id else None,
|
||
)
|
||
elif not _bp_text and _bp_media_urls:
|
||
_bp_text = _build_media_placeholder(_bp_event)
|
||
logger.info(
|
||
"Backup interrupt detected for session %s "
|
||
"(monitor task state: %s)",
|
||
session_key,
|
||
"done" if interrupt_monitor.done() else "running",
|
||
)
|
||
_backup_agent.interrupt(_bp_text)
|
||
_interrupt_detected.set()
|
||
# Abort streaming TTS on barge-in (#60671).
|
||
_stts = streaming_tts_consumer_holder[0]
|
||
if _stts is not None:
|
||
_stts.abort("barge-in")
|
||
|
||
async def _run_agent_await_turn_worker(
|
||
self,
|
||
worker: "GatewayRunner._RunAgentWorker",
|
||
*,
|
||
source: SessionSource,
|
||
session_key: Optional[str],
|
||
agent_holder: list,
|
||
result_holder: list,
|
||
tools_holder: list,
|
||
_interrupt_detected: "asyncio.Event",
|
||
interrupt_monitor: "asyncio.Task",
|
||
streaming_tts_consumer_holder: list,
|
||
_status_thread_metadata: Optional[Dict[str, Any]],
|
||
) -> Any:
|
||
"""Poll the executor future (inactivity timeout + backup interrupt checks); return its result.
|
||
|
||
On inactivity timeout the result is a synthetic failed run dict carrying the diagnostic.
|
||
"""
|
||
from gateway.run import (
|
||
_INTERRUPT_REASON_TIMEOUT,
|
||
_abandon_timed_out_gateway_turn,
|
||
_interim_metadata,
|
||
request_hard_interrupt,
|
||
)
|
||
_warning_fired = False
|
||
_inactivity_timeout = False
|
||
_POLL_INTERVAL = 5.0
|
||
|
||
if worker.agent_timeout is None:
|
||
# Unlimited — still poll periodically for backup interrupt
|
||
# detection in case monitor_for_interrupt() silently died.
|
||
response = None
|
||
while True:
|
||
done, _ = await asyncio.wait(
|
||
{worker.executor_task}, timeout=_POLL_INTERVAL
|
||
)
|
||
if done:
|
||
response = worker.executor_task.result()
|
||
break
|
||
# Backup interrupt check: if the monitor task died or
|
||
# missed the interrupt, catch it here.
|
||
await self._run_agent_backup_interrupt_check(
|
||
source,
|
||
session_key,
|
||
agent_holder,
|
||
_interrupt_detected,
|
||
interrupt_monitor,
|
||
streaming_tts_consumer_holder,
|
||
)
|
||
|
||
else:
|
||
# Poll the agent's built-in activity tracker (updated by _touch_activity() on every tool
|
||
# call, API call, and stream delta) every few seconds.
|
||
response = None
|
||
while True:
|
||
done, _ = await asyncio.wait(
|
||
{worker.executor_task}, timeout=_POLL_INTERVAL
|
||
)
|
||
if done:
|
||
# Prefer the real result when the worker finished even if the watchdog fired in
|
||
# the same window: the completed run already persisted its reply, so the "agent
|
||
# inactive" diagnostic would contradict the stored transcript.
|
||
response = worker.executor_task.result()
|
||
break
|
||
if worker.timeout_fired.is_set():
|
||
_inactivity_timeout = True
|
||
break
|
||
# Agent still running — check inactivity.
|
||
_agent_ref = agent_holder[0]
|
||
_idle_secs = 0.0
|
||
if _agent_ref and hasattr(_agent_ref, "get_activity_summary"):
|
||
try:
|
||
_act = _agent_ref.get_activity_summary()
|
||
_idle_secs = _act.get("seconds_since_activity", 0.0)
|
||
except Exception:
|
||
pass
|
||
# Staged warning: fire once before escalating to full timeout.
|
||
if (not _warning_fired and worker.agent_warning is not None
|
||
and _idle_secs >= worker.agent_warning):
|
||
_warning_fired = True
|
||
_warn_adapter = self._adapter_for_source(source)
|
||
if _warn_adapter:
|
||
_elapsed_warn = int(worker.agent_warning // 60) or 1
|
||
_remaining_mins = int((worker.agent_timeout - worker.agent_warning) // 60) or 1
|
||
try:
|
||
await _warn_adapter.send(
|
||
source.chat_id,
|
||
f"⚠️ No activity for {_elapsed_warn} min. "
|
||
f"If the agent does not respond soon, it will "
|
||
f"be timed out in {_remaining_mins} min. "
|
||
f"You can continue waiting or use /reset.",
|
||
metadata=_interim_metadata(_status_thread_metadata),
|
||
)
|
||
except Exception as _warn_err:
|
||
logger.debug("Inactivity warning send error: %s", _warn_err)
|
||
if _idle_secs >= worker.agent_timeout:
|
||
_inactivity_timeout = True
|
||
threading.Thread(
|
||
target=_abandon_timed_out_gateway_turn,
|
||
kwargs={
|
||
"agent_holder": agent_holder,
|
||
"task_id": worker.task_id,
|
||
"process_baseline": worker.process_baseline,
|
||
"worker_done": worker.worker_done,
|
||
"timeout_fired": worker.timeout_fired,
|
||
"cleanup_lock": worker.cleanup_lock,
|
||
"is_still_current": worker.is_current,
|
||
},
|
||
name=f"gateway-turn-reaper-{worker.task_id[:12]}",
|
||
daemon=True,
|
||
).start()
|
||
break
|
||
# Backup interrupt check (same as unlimited path).
|
||
await self._run_agent_backup_interrupt_check(
|
||
source,
|
||
session_key,
|
||
agent_holder,
|
||
_interrupt_detected,
|
||
interrupt_monitor,
|
||
streaming_tts_consumer_holder,
|
||
)
|
||
|
||
if _inactivity_timeout:
|
||
# Build a diagnostic summary from the agent's activity tracker.
|
||
_timed_out_agent = agent_holder[0]
|
||
_activity = {}
|
||
if _timed_out_agent and hasattr(_timed_out_agent, "get_activity_summary"):
|
||
with suppress(Exception):
|
||
_activity = _timed_out_agent.get_activity_summary()
|
||
|
||
_last_desc = _activity.get("last_activity_desc", "unknown")
|
||
_secs_ago = _activity.get("seconds_since_activity", 0)
|
||
_cur_tool = _activity.get("current_tool")
|
||
_iter_n = _activity.get("api_call_count", 0)
|
||
_iter_max = _activity.get("max_iterations", 0)
|
||
|
||
logger.error(
|
||
"Agent idle for %.0fs (timeout %.0fs) in session %s "
|
||
"| last_activity=%s | iteration=%s/%s | tool=%s",
|
||
_secs_ago, worker.agent_timeout, session_key,
|
||
_last_desc, _iter_n, _iter_max,
|
||
_cur_tool or "none",
|
||
)
|
||
|
||
# Interrupt the agent if it's still running so the thread
|
||
# pool worker is freed.
|
||
if _timed_out_agent:
|
||
request_hard_interrupt(_timed_out_agent, _INTERRUPT_REASON_TIMEOUT)
|
||
|
||
_timeout_mins = int(worker.agent_timeout // 60) or 1
|
||
|
||
# Construct a user-facing message with diagnostic context.
|
||
_diag_lines = [
|
||
f"⏱️ Agent inactive for {_timeout_mins} min — no tool calls "
|
||
f"or API responses."
|
||
]
|
||
if _cur_tool:
|
||
_diag_lines.append(
|
||
f"The agent appears stuck on tool `{_cur_tool}` "
|
||
f"({_secs_ago:.0f}s since last activity, "
|
||
f"iteration {_iter_n}/{_iter_max})."
|
||
)
|
||
else:
|
||
_diag_lines.append(
|
||
f"Last activity: {_last_desc} ({_secs_ago:.0f}s ago, "
|
||
f"iteration {_iter_n}/{_iter_max}). "
|
||
"The agent may have been waiting on an API response."
|
||
)
|
||
_diag_lines.append(
|
||
"To increase the limit, set agent.gateway_timeout in config.yaml "
|
||
"(value in seconds, 0 = no limit) and restart the gateway.\n"
|
||
"Try again, or use /reset to start fresh."
|
||
)
|
||
|
||
response = {
|
||
"final_response": "\n".join(_diag_lines),
|
||
"messages": result_holder[0].get("messages", []) if result_holder[0] else [],
|
||
"api_calls": _iter_n,
|
||
"tools": tools_holder[0] or [],
|
||
"history_offset": 0,
|
||
"failed": True,
|
||
}
|
||
return response
|
||
|
||
def _run_agent_evict_on_fallback(
|
||
self, session_key: Optional[str], agent_holder: list, result_holder: list,
|
||
) -> None:
|
||
# Persist fallback-model switches so /model shows the actually-active model. Skip
|
||
# eviction when the run failed — evicting forces MCP reinit on the next message for no
|
||
# benefit (bad model → fallback → evict → recreate → same 400 loop burning CPU).
|
||
from gateway.run import _resolve_gateway_model
|
||
_agent = agent_holder[0]
|
||
_result_for_fb = result_holder[0]
|
||
_run_failed = _result_for_fb.get("failed") if _result_for_fb else False
|
||
if _agent is not None and hasattr(_agent, 'model') and not _run_failed:
|
||
_cfg_model = _resolve_gateway_model()
|
||
# Normalize _cfg_model as AIAgent.__init__ does so a vendor-prefixed config value
|
||
# matches the agent's stripped model on native providers — otherwise the cached agent
|
||
# is evicted every turn, destroying prompt caching. Aggregators keep the vendor slug.
|
||
try:
|
||
from hermes_cli.model_normalize import (
|
||
_AGGREGATOR_PROVIDERS,
|
||
normalize_model_for_provider,
|
||
)
|
||
_agent_provider = getattr(_agent, 'provider', '') or ''
|
||
if _agent_provider and _agent_provider not in _AGGREGATOR_PROVIDERS:
|
||
_cfg_model = normalize_model_for_provider(_cfg_model, _agent_provider)
|
||
except Exception:
|
||
pass
|
||
if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent.model):
|
||
# Fallback activated on a successful run — evict cached
|
||
# agent so the next message retries the primary model.
|
||
self._evict_cached_agent(session_key)
|
||
|
||
async def _run_agent_finalize_streaming_tts(
|
||
self,
|
||
streaming_tts_consumer_holder: list,
|
||
adapter: Any,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
) -> None:
|
||
# Finalize the streaming-TTS consumer. finish() runs on the outer event-loop thread so
|
||
# early returns from run_sync are also finalised. wait_complete() drains queued audio;
|
||
# on timeout abort unconditionally — if audio was audible keep suppression (no replay
|
||
# from the start); if not, the whole-file fallback is permitted.
|
||
_stts = streaming_tts_consumer_holder[0]
|
||
if _stts is not None:
|
||
_stts.finish()
|
||
try:
|
||
await _stts.wait_complete(timeout=10.0)
|
||
except Exception as _stts_done_err:
|
||
logger.debug("streaming TTS wait_complete error: %s", _stts_done_err)
|
||
if not _stts.done:
|
||
# Timeout before or after audible audio: abort to free the consumer task. Audible
|
||
# streams retain suppression; silent streams stay eligible for whole-file fallback.
|
||
_stts.abort("streaming TTS finalisation timeout")
|
||
await _stts.wait_complete(timeout=2.0)
|
||
if _stts.suppress_whole_file and adapter is not None:
|
||
_mark_turn = getattr(adapter, "_mark_streaming_tts_completed_turn", None)
|
||
if callable(_mark_turn):
|
||
_mark_turn(session_key, run_generation)
|
||
|
||
async def _run_agent_drain_pending(
|
||
self,
|
||
result: Any,
|
||
adapter: Any,
|
||
source: SessionSource,
|
||
session_key: Optional[str],
|
||
) -> Tuple[Any, Optional[str]]:
|
||
"""Dequeue the adapter's pending / interrupt / leftover-steer follow-up.
|
||
|
||
Returns ``(pending_event, pending)``.
|
||
"""
|
||
from gateway.run import (
|
||
_build_media_placeholder,
|
||
_dequeue_pending_event,
|
||
_is_control_interrupt_message,
|
||
)
|
||
# Get pending message from adapter.
|
||
# Use session_key (not source.chat_id) to match adapter's storage keys.
|
||
pending_event = None
|
||
pending = None
|
||
if result and adapter and session_key:
|
||
pending_event = _dequeue_pending_event(adapter, session_key)
|
||
# /queue overflow: after consuming the adapter's "next-up" slot, promote the next
|
||
# queued event into it so the recursive run's drain will see it. Keeping the slot
|
||
# occupied for the whole FIFO chain preserves order and makes a mid-chain /queue
|
||
# route to overflow instead of jumping the queue.
|
||
pending_event = self._promote_queued_event(session_key, adapter, pending_event)
|
||
if result.get("interrupted") and not pending_event and result.get("interrupt_message"):
|
||
interrupt_message = result.get("interrupt_message")
|
||
if _is_control_interrupt_message(interrupt_message):
|
||
logger.info(
|
||
"Ignoring control interrupt message for session %s: %s",
|
||
session_key or "?",
|
||
interrupt_message,
|
||
)
|
||
else:
|
||
pending = interrupt_message
|
||
elif pending_event:
|
||
# Transcribe audio on the dequeued event BEFORE it becomes the next user turn, so
|
||
# queued/interrupting voice messages drain with the real transcript, not a file path.
|
||
_pending_text = pending_event.text or ""
|
||
_media_urls = getattr(pending_event, "media_urls", None) or []
|
||
if self._pending_event_audio_paths(pending_event):
|
||
pending, _ = await self._transcribe_and_echo_pending_voice(
|
||
pending_event,
|
||
adapter,
|
||
source,
|
||
_pending_text,
|
||
log_context="Voice-drain",
|
||
metadata={"thread_id": source.thread_id} if source.thread_id else None,
|
||
)
|
||
if not pending:
|
||
pending = _build_media_placeholder(pending_event)
|
||
else:
|
||
pending = _pending_text or _build_media_placeholder(pending_event)
|
||
if pending:
|
||
logger.debug("Processing queued message after agent completion: '%s...'", pending[:40])
|
||
|
||
# Leftover /steer: a steer arriving after the last tool batch (e.g. during the final API
|
||
# call) comes back in result["pending_steer"]; deliver it as the next user turn, not drop it.
|
||
if result and not pending and not pending_event:
|
||
_leftover_steer = result.get("pending_steer")
|
||
if _leftover_steer:
|
||
pending = _leftover_steer
|
||
logger.debug("Delivering leftover /steer as next turn: '%s...'", pending[:40])
|
||
|
||
# Safety net: if the pending text is a slash command (e.g. "/stop", "/new"), discard it
|
||
# — commands should never be passed to the agent as user input.
|
||
if pending and pending.strip().startswith("/"):
|
||
_pending_parts = pending.strip().split(None, 1)
|
||
_pending_cmd_word = _pending_parts[0][1:].lower() if _pending_parts else ""
|
||
if _pending_cmd_word:
|
||
try:
|
||
from hermes_cli.commands import resolve_command as _rc_pending
|
||
if _rc_pending(_pending_cmd_word):
|
||
logger.info(
|
||
"Discarding command '/%s' from pending queue — "
|
||
"commands must not be passed as agent input",
|
||
_pending_cmd_word,
|
||
)
|
||
pending_event = None
|
||
pending = None
|
||
except Exception:
|
||
pass
|
||
|
||
if self._draining and (pending_event or pending):
|
||
logger.info(
|
||
"Discarding pending follow-up for session %s during gateway %s",
|
||
session_key or "?",
|
||
self._status_action_label(),
|
||
)
|
||
pending_event = None
|
||
pending = None
|
||
return pending_event, pending
|
||
|
||
async def _run_agent_deliver_first_response(
|
||
self,
|
||
*,
|
||
source: SessionSource,
|
||
adapter: Any,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
event_message_id: Optional[str],
|
||
response: Any,
|
||
result: Any,
|
||
stream_consumer_holder: list,
|
||
stream_task: Any,
|
||
_status_thread_metadata: Optional[Dict[str, Any]],
|
||
) -> None:
|
||
# Queued message after normal completion: deliver the first response before the
|
||
# queued follow-up, unless streaming already delivered it.
|
||
_sc = stream_consumer_holder[0]
|
||
if _sc and stream_task:
|
||
try:
|
||
await asyncio.wait_for(stream_task, timeout=5.0)
|
||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||
stream_task.cancel()
|
||
with suppress(asyncio.CancelledError):
|
||
await stream_task
|
||
except Exception as e:
|
||
logger.debug("Stream consumer wait before queued message failed: %s", e)
|
||
# The queued branch needs raw ``result`` for interruption, history, and
|
||
# recursion state, but delivery must use the finalized task result — it carries
|
||
# empty/failure normalization and final-response processing from _run_agent_task.
|
||
_delivery_result = response if isinstance(response, dict) else (result or {})
|
||
_previewed = bool(_delivery_result.get("response_previewed"))
|
||
first_response = _delivery_result.get("final_response", "")
|
||
_already_streamed = self._run_agent_stream_confirmed_final_delivery(
|
||
_sc,
|
||
first_response,
|
||
previewed=_previewed,
|
||
)
|
||
# Same predicate as the normal completed-turn path: this direct queued-send branch
|
||
# predates intentional-silence filtering and would leak the literal marker.
|
||
try:
|
||
from gateway.response_filters import is_intentional_silence_agent_result
|
||
_intentional_silence = is_intentional_silence_agent_result(
|
||
_delivery_result, first_response,
|
||
)
|
||
except Exception:
|
||
_intentional_silence = False
|
||
if _intentional_silence:
|
||
logger.info(
|
||
"Queued follow-up for session %s: suppressing intentional silence marker before continuing.",
|
||
session_key or "?",
|
||
)
|
||
elif first_response:
|
||
try:
|
||
if _already_streamed:
|
||
logger.info(
|
||
"Queued follow-up for session %s: final text delivery confirmed; delivering explicit media before continuing.",
|
||
session_key or "?",
|
||
)
|
||
else:
|
||
logger.info(
|
||
"Queued follow-up for session %s: final stream delivery not confirmed; sending first response before continuing.",
|
||
session_key or "?",
|
||
)
|
||
await self._deliver_queued_first_response(
|
||
first_response,
|
||
source=source,
|
||
adapter=adapter,
|
||
metadata=_status_thread_metadata,
|
||
event_message_id=event_message_id,
|
||
text_already_delivered=_already_streamed,
|
||
deliver_media=not _delivery_result.get("failed"),
|
||
stream_consumer=_sc,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("Failed to send first response before queued message: %s", e)
|
||
# Release deferred bg-review notifications now that the first response is delivered:
|
||
# pop from the adapter's callback dict (no double-fire in base.py's finally) and call.
|
||
if getattr(type(adapter), "pop_post_delivery_callback", None) is not None:
|
||
_bg_cb = adapter.pop_post_delivery_callback(
|
||
session_key,
|
||
generation=run_generation,
|
||
)
|
||
if callable(_bg_cb):
|
||
try:
|
||
_bg_result = _bg_cb()
|
||
if inspect.isawaitable(_bg_result):
|
||
await _bg_result
|
||
except Exception:
|
||
pass
|
||
elif adapter and hasattr(adapter, "_post_delivery_callbacks"):
|
||
_bg_cb = adapter._post_delivery_callbacks.pop(session_key, None)
|
||
if callable(_bg_cb):
|
||
try:
|
||
_bg_result = _bg_cb()
|
||
if inspect.isawaitable(_bg_result):
|
||
await _bg_result
|
||
except Exception:
|
||
pass
|
||
|
||
async def _run_agent_queued_followup(
|
||
self,
|
||
*,
|
||
source: SessionSource,
|
||
adapter: Any,
|
||
session_id: str,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
_interrupt_depth: int,
|
||
event_message_id: Optional[str],
|
||
context_prompt: str,
|
||
history: List[Dict[str, Any]],
|
||
pending: Optional[str],
|
||
pending_event: Any,
|
||
response: Any,
|
||
result: Any,
|
||
result_holder: list,
|
||
stream_consumer_holder: list,
|
||
stream_task: Any,
|
||
_status_thread_metadata: Optional[Dict[str, Any]],
|
||
) -> Any:
|
||
"""Run the queued / interrupting follow-up as the next turn (recursive ``_run_agent``)."""
|
||
from gateway.run import _preserve_queued_followup_history_offset, merge_pending_message_event
|
||
logger.debug("Processing pending message: '%s...'", pending[:40])
|
||
|
||
# Clear the adapter's interrupt event so the next _run_agent call doesn't re-trigger the
|
||
# interrupt before the new agent's first API call (infinite loop otherwise).
|
||
if adapter and hasattr(adapter, '_active_sessions') and session_key and session_key in adapter._active_sessions:
|
||
adapter._active_sessions[session_key].clear()
|
||
|
||
# Cap recursion depth to prevent resource exhaustion when the
|
||
# user sends multiple messages while the agent keeps failing. (#816)
|
||
if _interrupt_depth >= self._MAX_INTERRUPT_DEPTH:
|
||
logger.warning(
|
||
"Interrupt recursion depth %d reached for session %s — "
|
||
"queueing message instead of recursing.",
|
||
_interrupt_depth, session_key,
|
||
)
|
||
adapter = self._adapter_for_source(source)
|
||
if adapter and pending_event:
|
||
merge_pending_message_event(adapter._pending_messages, session_key, pending_event)
|
||
elif adapter and hasattr(adapter, 'queue_message'):
|
||
adapter.queue_message(session_key, pending)
|
||
return result_holder[0] or {"final_response": response, "messages": history}
|
||
|
||
was_interrupted = result.get("interrupted")
|
||
if not was_interrupted:
|
||
await self._run_agent_deliver_first_response(
|
||
source=source,
|
||
adapter=adapter,
|
||
session_key=session_key,
|
||
run_generation=run_generation,
|
||
event_message_id=event_message_id,
|
||
response=response,
|
||
result=result,
|
||
stream_consumer_holder=stream_consumer_holder,
|
||
stream_task=stream_task,
|
||
_status_thread_metadata=_status_thread_metadata,
|
||
)
|
||
# else: interrupted — discard the response ("Operation interrupted." is noise; the user
|
||
# knows they sent a new message).
|
||
|
||
updated_history = result.get("messages", history)
|
||
next_source = source
|
||
next_message = pending
|
||
next_message_id = None
|
||
next_channel_prompt = None
|
||
next_session_key = session_key
|
||
# Carry the pending event's message_type into the recursive call so queued voice turns
|
||
# can stream TTS and re-mark the generation for the final delivered turn.
|
||
next_message_type = None
|
||
if pending_event is not None:
|
||
next_source = getattr(pending_event, "source", None) or source
|
||
if self._is_goal_continuation_event(pending_event) and not self._goal_still_active_for_session(session_id):
|
||
logger.info(
|
||
"Discarding stale goal continuation for session %s — goal is no longer active",
|
||
session_key or "?",
|
||
)
|
||
return result
|
||
# Resolve the follow-up's session key BEFORE preparing the inbound text:
|
||
# _prepare_inbound_message_text buffers native image paths under the key given, and
|
||
# the recursive _run_agent consumes them under next_session_key — mismatch drops them.
|
||
try:
|
||
next_session_key = self._session_key_for_source(next_source)
|
||
except Exception:
|
||
logger.debug(
|
||
"Queued follow-up session-key resolution failed; reusing %s",
|
||
session_key or "?",
|
||
exc_info=True,
|
||
)
|
||
next_message = await self._prepare_profile_scoped_inbound_message_text(
|
||
event=pending_event,
|
||
source=next_source,
|
||
history=updated_history,
|
||
session_key=next_session_key,
|
||
)
|
||
if next_message is None:
|
||
return result
|
||
next_message_id = self._reply_anchor_for_event(pending_event)
|
||
next_channel_prompt = getattr(pending_event, "channel_prompt", None)
|
||
next_message_type = getattr(pending_event, "message_type", None)
|
||
|
||
# Clear the prior logical turn's completed streaming marker so the recursive turn's
|
||
# streaming TTS isn't suppressed by that completion.
|
||
_clear_adapter = self._adapter_for_source(source)
|
||
if _clear_adapter is not None and session_key and run_generation is not None:
|
||
_completed_turns = getattr(_clear_adapter, "_streaming_tts_completed_turns", None)
|
||
if _completed_turns is not None:
|
||
_prior_key = getattr(_clear_adapter, "_streaming_tts_turn_key", None)
|
||
if callable(_prior_key):
|
||
_pk = _prior_key(session_key, run_generation)
|
||
if _pk:
|
||
_completed_turns.discard(_pk)
|
||
|
||
# Restart the typing indicator for the follow-up turn; the outer
|
||
# _process_message_background typing task is alive but may be stale.
|
||
_followup_adapter = self._adapter_for_source(source)
|
||
if _followup_adapter:
|
||
with suppress(Exception):
|
||
await _followup_adapter.send_typing(
|
||
source.chat_id,
|
||
metadata=_status_thread_metadata,
|
||
)
|
||
|
||
# Re-baseline the cached agent's message_count before recursing into the /queue follow-up:
|
||
# the coherence guard would otherwise rebuild on OUR OWN flushed rows and destroy the
|
||
# prompt-cache prefix; _handle_message_with_agent re-baselines only after the chain ends.
|
||
await self._refresh_agent_cache_message_count(session_key, session_id)
|
||
|
||
followup_result = await self._run_agent(
|
||
message=next_message,
|
||
context_prompt=context_prompt,
|
||
history=updated_history,
|
||
source=next_source,
|
||
session_id=session_id,
|
||
session_key=next_session_key,
|
||
run_generation=run_generation,
|
||
_interrupt_depth=_interrupt_depth + 1,
|
||
event_message_id=next_message_id,
|
||
channel_prompt=next_channel_prompt,
|
||
message_type=next_message_type,
|
||
)
|
||
return _preserve_queued_followup_history_offset(result, followup_result)
|
||
|
||
async def _run_agent_cleanup_turn_tasks(
|
||
self,
|
||
*,
|
||
progress_task: Any,
|
||
log_task: Any,
|
||
interrupt_monitor: "asyncio.Task",
|
||
_notify_task: "asyncio.Task",
|
||
tracking_task: "asyncio.Task",
|
||
stream_task: Any,
|
||
stream_consumer_holder: list,
|
||
streaming_tts_consumer_holder: list,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
) -> None:
|
||
"""``finally`` half of a turn: cancel background tasks, flush stream, release the session slot."""
|
||
# Stop progress sender, interrupt monitor, and notification task
|
||
if progress_task:
|
||
progress_task.cancel()
|
||
if log_task:
|
||
log_task.cancel()
|
||
interrupt_monitor.cancel()
|
||
_notify_task.cancel()
|
||
|
||
# Wait for stream consumer to finish its final edit
|
||
if stream_task:
|
||
# If the agent never created a stream consumer (non-streaming path, or a test stub
|
||
# returning synchronously) there is nothing to flush — cancel now instead of waiting
|
||
# out the 5s timeout polling for a consumer that will never arrive.
|
||
_has_stream_consumer = (
|
||
stream_consumer_holder
|
||
and stream_consumer_holder[0] is not None
|
||
)
|
||
if not _has_stream_consumer:
|
||
stream_task.cancel()
|
||
with suppress(asyncio.CancelledError):
|
||
await stream_task
|
||
else:
|
||
try:
|
||
await asyncio.wait_for(stream_task, timeout=5.0)
|
||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||
stream_task.cancel()
|
||
with suppress(asyncio.CancelledError):
|
||
await stream_task
|
||
|
||
# Unconditional abort + bounded wait for the streaming-TTS consumer: covers cancellation /
|
||
# exception paths where the normal finalisation block was skipped.
|
||
_stts_finally = streaming_tts_consumer_holder[0]
|
||
if _stts_finally is not None and not _stts_finally.done:
|
||
_stts_finally.abort("cleanup")
|
||
with suppress(Exception):
|
||
await _stts_finally.wait_complete(timeout=2.0)
|
||
|
||
# Clean up tracking
|
||
tracking_task.cancel()
|
||
if session_key:
|
||
# Release the slot only if this run's generation still owns it: a /stop or /new that
|
||
# bumped the generation while we unwound already installed its own state; keep it.
|
||
self._release_running_agent_state(
|
||
session_key, run_generation=run_generation
|
||
)
|
||
if self._draining:
|
||
self._update_runtime_status("draining")
|
||
|
||
# Wait for cancelled tasks
|
||
for task in [progress_task, log_task, interrupt_monitor, tracking_task, _notify_task]:
|
||
if task:
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception:
|
||
# A background task that died of a non-cancellation error (transport drop in
|
||
# a progress/card publish) must not abort the cleanup path — everything
|
||
# after this loop (final-delivery bookkeeping) still runs (review B7).
|
||
logger.debug(
|
||
"background turn task failed during cleanup",
|
||
exc_info=True,
|
||
)
|
||
|
||
async def _run_agent_mark_streamed_delivery(
|
||
self,
|
||
response: Any,
|
||
stream_consumer_holder: list,
|
||
source: SessionSource,
|
||
session_key: Optional[str],
|
||
) -> None:
|
||
# If streaming already delivered the response, skip the caller's send() — but never when the
|
||
# agent failed (the error is unseen content) or on "(empty)": interim text ("Let me search…")
|
||
# set already_sent but is NOT the final answer; suppressing would leave the user with silence.
|
||
_sc = stream_consumer_holder[0]
|
||
if isinstance(response, dict) and not response.get("failed"):
|
||
_final = response.get("final_response") or ""
|
||
_is_empty_sentinel = not _final or _final == "(empty)"
|
||
# response_previewed means interim_assistant_callback already saw the final text, but only
|
||
# suppress the send if that exact text was delivered — unrelated commentary/progress isn't it.
|
||
_previewed = bool(response.get("response_previewed"))
|
||
_content_delivered = bool(
|
||
_sc and getattr(_sc, "final_content_delivered", False)
|
||
)
|
||
# A *successful* finalize edit can still carry only the last preview snapshot, and both
|
||
# suppression flags reflect call success, not content. Reconcile against the recorded
|
||
# turn-final payload: on mismatch (False, incl. payload-less split delivery) neither flag
|
||
# may suppress the final send; None (no record) keeps legacy trust.
|
||
_stale_finalized = False
|
||
if _content_delivered and not _is_empty_sentinel:
|
||
_matcher = getattr(_sc, "delivered_final_matches", None)
|
||
if callable(_matcher):
|
||
try:
|
||
_stale_finalized = _matcher(_final) is False
|
||
except Exception:
|
||
_stale_finalized = False
|
||
if _stale_finalized:
|
||
_content_delivered = False
|
||
# Plugin hooks (e.g. transform_llm_output) may append content after streaming finished — when
|
||
# transformed, always send the final version so the appended content reaches the client.
|
||
_transformed = bool(response.get("response_transformed"))
|
||
# Suppress the normal send only when the actual final reply reached the user (streamed, or
|
||
# interim preview of that *exact* text); commentary shown during a compression/split isn't it.
|
||
_streamed = self._run_agent_stream_confirmed_final_delivery(
|
||
_sc,
|
||
_final,
|
||
previewed=_previewed,
|
||
)
|
||
if not _is_empty_sentinel and not _transformed and (_streamed or _content_delivered):
|
||
logger.info(
|
||
"Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s content_delivered=%s).",
|
||
session_key or "?",
|
||
_streamed,
|
||
_previewed,
|
||
_content_delivered,
|
||
)
|
||
response["already_sent"] = True
|
||
elif not _is_empty_sentinel and not _transformed and _stale_finalized and _sc is not None:
|
||
# Stale finalize: the streamed message holds only the last preview snapshot. Edit it
|
||
# up to the complete response; on edit failure leave already_sent unset so the normal
|
||
# send delivers. Not for split delivery: message_id is only the LAST chunk, so editing
|
||
# it would repeat every sealed head chunk — fall through to the normal send.
|
||
_sc_msg_id = _sc.message_id
|
||
_sc_adapter = getattr(_sc, "adapter", None)
|
||
if getattr(_sc, "_turn_split_delivery", False):
|
||
logger.info(
|
||
"Stale streamed finalize detected for session %s on a multi-message split; skipping the in-place reconciliation edit and delivering the complete response via normal final send (#78541).",
|
||
session_key or "?",
|
||
)
|
||
elif _sc_msg_id and _sc_msg_id != "__no_edit__" and _sc_adapter is not None:
|
||
try:
|
||
_reconcile_res = await _sc_adapter.edit_message(
|
||
chat_id=source.chat_id,
|
||
message_id=_sc_msg_id,
|
||
content=_final,
|
||
finalize=True,
|
||
)
|
||
if getattr(_reconcile_res, "success", True):
|
||
response["already_sent"] = True
|
||
logger.info(
|
||
"Reconciled stale streamed finalize for session %s: edited message %s with the complete response (#71643).",
|
||
session_key or "?", _sc_msg_id,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"Stale-finalize reconciliation edit failed for session %s (%s); sending complete response via normal final send.",
|
||
session_key or "?",
|
||
getattr(_reconcile_res, "error", None),
|
||
)
|
||
except Exception as _edit_err:
|
||
logger.warning(
|
||
"Stale-finalize reconciliation edit failed for session %s: %s; sending complete response via normal final send.",
|
||
session_key or "?", _edit_err,
|
||
)
|
||
else:
|
||
logger.info(
|
||
"Stale streamed finalize detected for session %s with no editable message; delivering complete response via normal final send (#71643).",
|
||
session_key or "?",
|
||
)
|
||
elif not _is_empty_sentinel and _transformed and _sc is not None:
|
||
# Plugin hooks transformed the response after streaming — edit the
|
||
# existing streamed message instead of sending a duplicate.
|
||
_sc_msg_id = _sc.message_id
|
||
if _sc_msg_id:
|
||
try:
|
||
await _sc.adapter.edit_message(
|
||
chat_id=source.chat_id,
|
||
message_id=_sc_msg_id,
|
||
content=response["final_response"],
|
||
finalize=True,
|
||
)
|
||
response["already_sent"] = True
|
||
logger.info(
|
||
"Edited streamed message %s for session %s to include plugin-transformed content.",
|
||
_sc_msg_id, session_key or "?",
|
||
)
|
||
except Exception as _edit_err:
|
||
logger.warning(
|
||
"Failed to edit streamed message for session %s: %s",
|
||
session_key or "?", _edit_err,
|
||
)
|
||
elif _sc is not None and not _is_empty_sentinel:
|
||
# DUPLICATE-RISK DIAGNOSTIC: a stream consumer existed for this turn but suppression
|
||
# did NOT fire, so the gateway's normal final-send is about to run. Log the decision
|
||
# inputs so a recurrence can be pinned to "signal never set" vs "ack-pending race".
|
||
logger.warning(
|
||
"Normal final-send NOT suppressed despite active stream "
|
||
"consumer for session %s: streamed=%s previewed=%s "
|
||
"content_delivered=%s transformed=%s final_len=%d — "
|
||
"possible duplicate send (see wecom ack-timeout RCA).",
|
||
session_key or "?",
|
||
_streamed,
|
||
_previewed,
|
||
_content_delivered,
|
||
_transformed,
|
||
len(_final),
|
||
)
|
||
|
||
def _run_agent_schedule_bubble_cleanup(
|
||
self,
|
||
response: Any,
|
||
_cleanup_progress: bool,
|
||
_cleanup_adapter: Any,
|
||
_cleanup_msg_ids: List[str],
|
||
source: SessionSource,
|
||
session_key: Optional[str],
|
||
run_generation: Optional[int],
|
||
) -> None:
|
||
# Schedule deletion of tracked temporary progress bubbles after the final response lands; failed
|
||
# runs keep them as breadcrumbs. Only on adapters with ``delete_message``; failures swallowed.
|
||
from gateway.run import safe_schedule_threadsafe
|
||
if (
|
||
_cleanup_progress
|
||
and _cleanup_adapter is not None
|
||
and _cleanup_msg_ids
|
||
and session_key
|
||
and isinstance(response, dict)
|
||
and not response.get("failed")
|
||
and hasattr(_cleanup_adapter, "register_post_delivery_callback")
|
||
):
|
||
_ids_snapshot = list(_cleanup_msg_ids)
|
||
_chat_id_snapshot = source.chat_id
|
||
_adapter_snapshot = _cleanup_adapter
|
||
_loop_snapshot = asyncio.get_running_loop()
|
||
|
||
def _cleanup_temp_bubbles() -> None:
|
||
async def _delete_all() -> None:
|
||
for _mid in _ids_snapshot:
|
||
with suppress(Exception):
|
||
await _adapter_snapshot.delete_message(
|
||
_chat_id_snapshot, _mid
|
||
)
|
||
with suppress(Exception):
|
||
safe_schedule_threadsafe(
|
||
_delete_all(), _loop_snapshot,
|
||
logger=logger,
|
||
log_message="Temp bubble cleanup scheduling error",
|
||
)
|
||
|
||
try:
|
||
_cleanup_adapter.register_post_delivery_callback(
|
||
session_key,
|
||
_cleanup_temp_bubbles,
|
||
generation=run_generation,
|
||
)
|
||
except Exception as _rpe:
|
||
logger.debug("Post-delivery cleanup registration failed: %s", _rpe)
|
||
|
||
def _run_agent_bind_turn_wiring(
|
||
self,
|
||
turn_ctx: TurnContext,
|
||
turn_runner: TurnRunner,
|
||
source: SessionSource,
|
||
event_message_id: Optional[str],
|
||
_progress_metadata: Optional[dict],
|
||
_progress_reply_to: Optional[str],
|
||
_progress_thread_id: Any,
|
||
_relay_prospective_thread_id: Optional[str],
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Publish progress metadata, result holders and the sync→async bridges onto ``turn_ctx``.
|
||
|
||
Returns ``_status_thread_metadata``; the holders are read back via ``turn_ctx.*_holder``.
|
||
"""
|
||
# Extracted to TurnRunner.send_progress_messages; the threading metadata above is published
|
||
# onto the shared TurnContext where the original closure's captured locals were bound.
|
||
turn_ctx._progress_metadata = _progress_metadata
|
||
turn_ctx._progress_reply_to = _progress_reply_to
|
||
|
||
# We need to share the agent instance for interrupt support
|
||
agent_holder = [None] # Mutable container for the agent instance
|
||
turn_ctx.agent_holder = agent_holder
|
||
result_holder = [None] # Mutable container for the result
|
||
tools_holder = [None] # Mutable container for the tool definitions
|
||
stream_consumer_holder = [None] # Mutable container for stream consumer
|
||
# streaming PCM audio consumer. Created on the gateway event-loop thread (NOT in run_sync's
|
||
# executor worker) so outer finalisation / interrupt paths can reference it without a NameError.
|
||
streaming_tts_consumer_holder: list = [None]
|
||
turn_ctx.result_holder = result_holder
|
||
turn_ctx.tools_holder = tools_holder
|
||
turn_ctx.stream_consumer_holder = stream_consumer_holder
|
||
turn_ctx.streaming_tts_consumer_holder = streaming_tts_consumer_holder
|
||
|
||
# Bridge sync step_callback → async hooks.emit for agent:step events
|
||
_loop_for_step = asyncio.get_running_loop()
|
||
_hooks_ref = self.hooks
|
||
|
||
# Bridge extracted to TurnRunner._step_callback_sync; the loop and
|
||
# hooks refs bound just above are published at their original site.
|
||
turn_ctx._loop_for_step = _loop_for_step
|
||
turn_ctx._hooks_ref = _hooks_ref
|
||
turn_ctx._step_callback_sync = turn_runner._step_callback_sync
|
||
|
||
# Bridge sync event_callback → async hooks.emit for lifecycle events (e.g. session:compress
|
||
# after a compression split); extracted to TurnRunner._event_callback_sync.
|
||
turn_ctx._event_callback_sync = turn_runner._event_callback_sync
|
||
|
||
# Bridge sync status_callback → async adapter.send for context pressure
|
||
_status_adapter = self._adapter_for_source(source)
|
||
_status_chat_id = source.chat_id
|
||
_status_thread_metadata = self._run_agent_status_thread_metadata(
|
||
source, event_message_id, _progress_thread_id, _relay_prospective_thread_id,
|
||
)
|
||
|
||
# Bridge extracted to TurnRunner._status_callback_sync; publish the status wiring computed
|
||
# above onto the shared TurnContext at the exact original binding site.
|
||
turn_ctx._status_adapter = _status_adapter
|
||
turn_ctx._status_chat_id = _status_chat_id
|
||
turn_ctx._status_thread_metadata = _status_thread_metadata
|
||
turn_ctx._status_callback_sync = turn_runner._status_callback_sync
|
||
return _status_thread_metadata
|
||
|
||
async def _run_agent_notify_long_running(
|
||
self,
|
||
disp: "GatewayRunner._RunAgentDisplay",
|
||
*,
|
||
source: SessionSource,
|
||
session_key: Optional[str],
|
||
agent_holder: list,
|
||
_executor_task_holder: list,
|
||
_NOTIFY_INTERVAL: Optional[float],
|
||
_long_running_mode: str,
|
||
_notify_start: float,
|
||
_status_thread_metadata: Optional[Dict[str, Any]],
|
||
_cleanup_progress: bool,
|
||
_cleanup_msg_ids: List[str],
|
||
) -> None:
|
||
"""Periodic \"still working\" heartbeat (edited in place where the adapter supports it).
|
||
|
||
``_executor_task_holder[0]`` is populated once the executor future exists; tolerate the
|
||
brief window before then (it reads as None).
|
||
"""
|
||
from gateway.run import _interim_metadata, _non_conversational_metadata
|
||
if _NOTIFY_INTERVAL is None:
|
||
return # Notifications disabled (gateway_notify_interval: 0)
|
||
_notify_adapter = self._adapter_for_source(source)
|
||
if not _notify_adapter:
|
||
return
|
||
# Track the heartbeat message id to edit in place where supported (Telegram, Discord,
|
||
# Slack, ...) instead of a new "Still working" bubble every interval.
|
||
_heartbeat_msg_id: Optional[str] = None
|
||
while True:
|
||
await asyncio.sleep(_NOTIFY_INTERVAL)
|
||
# Stop heartbeating once this run no longer owns the session slot or the executor has
|
||
# finished, else a stale "running: delegate_task" bubble outlives its run. _executor_task
|
||
# is bound just after this task is scheduled; tolerate the brief window before then.
|
||
_exec_ref = _executor_task_holder[0]
|
||
if not self._should_emit_long_running_notification(
|
||
session_key, agent_holder[0], _exec_ref
|
||
):
|
||
break
|
||
_elapsed_mins = int((time.time() - _notify_start) // 60)
|
||
# Default heartbeat is terse (elapsed + current tool); the verbose iteration counter is
|
||
# gated on busy_ack_detail so users can opt in per platform.
|
||
_agent_ref = agent_holder[0]
|
||
_status_detail = ""
|
||
_want_iteration_detail = bool(
|
||
disp.resolve_display_setting(
|
||
disp.user_config,
|
||
disp.platform_key,
|
||
"busy_ack_detail",
|
||
True,
|
||
)
|
||
)
|
||
if _agent_ref and hasattr(_agent_ref, "get_activity_summary"):
|
||
try:
|
||
_a = _agent_ref.get_activity_summary()
|
||
_parts = []
|
||
if _want_iteration_detail:
|
||
_parts.append(
|
||
f"iteration {_a['api_call_count']}/{_a['max_iterations']}"
|
||
)
|
||
_action = _a.get("current_tool") or _a.get("last_activity_desc")
|
||
if _action:
|
||
_parts.append(str(_action))
|
||
if _parts:
|
||
_status_detail = " — " + ", ".join(_parts)
|
||
except Exception:
|
||
pass
|
||
_heartbeat_text = (
|
||
disp._generic_status_phrase("status")
|
||
if _long_running_mode == "generic"
|
||
else f"⏳ Working — {_elapsed_mins} min{_status_detail}"
|
||
)
|
||
try:
|
||
_notify_res = None
|
||
if _heartbeat_msg_id:
|
||
try:
|
||
_notify_res = await _notify_adapter.edit_message(
|
||
source.chat_id,
|
||
_heartbeat_msg_id,
|
||
_heartbeat_text,
|
||
)
|
||
except Exception as _ee:
|
||
logger.debug("Heartbeat edit failed: %s", _ee)
|
||
_notify_res = None
|
||
if not (_notify_res and getattr(_notify_res, "success", False)):
|
||
_notify_res = await _notify_adapter.send(
|
||
source.chat_id,
|
||
_heartbeat_text,
|
||
metadata=_interim_metadata(_non_conversational_metadata(_status_thread_metadata, platform=source.platform)),
|
||
)
|
||
if getattr(_notify_res, "success", False) and getattr(
|
||
_notify_res, "message_id", None
|
||
):
|
||
_heartbeat_msg_id = str(_notify_res.message_id)
|
||
if _cleanup_progress:
|
||
_cleanup_msg_ids.append(_heartbeat_msg_id)
|
||
except Exception as _ne:
|
||
logger.debug("Long-running notification error: %s", _ne)
|
||
|
||
async def _run_agent_inner(
|
||
self,
|
||
message: str,
|
||
context_prompt: str,
|
||
history: List[Dict[str, Any]],
|
||
source: SessionSource,
|
||
session_id: str,
|
||
session_key: str = None,
|
||
run_generation: Optional[int] = None,
|
||
_interrupt_depth: int = 0,
|
||
event_message_id: Optional[str] = None,
|
||
inbound_message_id: Optional[str] = None,
|
||
channel_prompt: Optional[str] = None,
|
||
moa_config: Optional[dict] = None,
|
||
persist_user_message: Optional[Any] = None,
|
||
persist_user_timestamp: Optional[float] = None,
|
||
persist_user_display_kind: Optional[str] = None,
|
||
message_type: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Run the agent; returns the full run_conversation result dict.
|
||
|
||
Keys: "final_response", "messages", "api_calls", "completed".
|
||
"""
|
||
from gateway.run import _float_env
|
||
# ---- Proxy mode: delegate to remote API server ----
|
||
if self._get_proxy_url():
|
||
return await self._run_agent_via_proxy(
|
||
message=message,
|
||
context_prompt=context_prompt,
|
||
history=history,
|
||
source=source,
|
||
session_id=session_id,
|
||
session_key=session_key,
|
||
run_generation=run_generation,
|
||
event_message_id=event_message_id,
|
||
)
|
||
|
||
from run_agent import AIAgent
|
||
|
||
disp = self._run_agent_display_settings(source)
|
||
_display_surface_mode = disp._display_surface_mode
|
||
needs_progress_queue = disp.needs_progress_queue
|
||
log_mode_enabled = disp.log_mode_enabled
|
||
log_queue = disp.log_queue
|
||
|
||
turn_ctx, turn_runner, _cleanup_adapter = self._run_agent_build_turn_context(
|
||
disp,
|
||
AIAgent,
|
||
message=message,
|
||
context_prompt=context_prompt,
|
||
history=history,
|
||
source=source,
|
||
session_id=session_id,
|
||
session_key=session_key,
|
||
run_generation=run_generation,
|
||
_interrupt_depth=_interrupt_depth,
|
||
event_message_id=event_message_id,
|
||
inbound_message_id=inbound_message_id,
|
||
channel_prompt=channel_prompt,
|
||
moa_config=moa_config,
|
||
persist_user_message=persist_user_message,
|
||
persist_user_timestamp=persist_user_timestamp,
|
||
persist_user_display_kind=persist_user_display_kind,
|
||
)
|
||
_cleanup_progress = turn_ctx._cleanup_progress
|
||
_cleanup_msg_ids = turn_ctx._cleanup_msg_ids
|
||
|
||
(
|
||
_progress_metadata,
|
||
_progress_reply_to,
|
||
_progress_thread_id,
|
||
_relay_prospective_thread_id,
|
||
) = self._run_agent_progress_threading(source, event_message_id, disp._native_slack_task_cards)
|
||
|
||
_status_thread_metadata = self._run_agent_bind_turn_wiring(
|
||
turn_ctx,
|
||
turn_runner,
|
||
source,
|
||
event_message_id,
|
||
_progress_metadata,
|
||
_progress_reply_to,
|
||
_progress_thread_id,
|
||
_relay_prospective_thread_id,
|
||
)
|
||
send_progress_messages = turn_runner.send_progress_messages
|
||
agent_holder = turn_ctx.agent_holder
|
||
result_holder = turn_ctx.result_holder
|
||
tools_holder = turn_ctx.tools_holder
|
||
stream_consumer_holder = turn_ctx.stream_consumer_holder
|
||
streaming_tts_consumer_holder = turn_ctx.streaming_tts_consumer_holder
|
||
|
||
self._run_agent_start_streaming_tts(
|
||
source, message_type, _status_thread_metadata, streaming_tts_consumer_holder,
|
||
)
|
||
|
||
# run_sync extracted to TurnRunner.run_sync (bound method; executor call unchanged). Its
|
||
# closed-over locals travel on turn_ctx; `nonlocal message` rebinds became ctx.message writes.
|
||
run_sync = turn_runner.run_sync
|
||
|
||
# Start the progress sender if enabled. Gate on needs_progress_queue (tool_progress OR
|
||
# thinking_progress), not tool_progress alone: the sender drains BOTH tool-progress lines and
|
||
# _thinking scratch bubbles — a tool_progress-only gate left thinking-only queues never drained.
|
||
progress_task = None
|
||
if needs_progress_queue:
|
||
progress_task = asyncio.create_task(send_progress_messages())
|
||
|
||
# Start the tool-call log writer when tool_progress == "log".
|
||
log_task = None
|
||
if log_mode_enabled:
|
||
log_task = asyncio.create_task(self._run_agent_write_tool_log(log_queue))
|
||
|
||
# Start stream consumer task — polls for consumer creation since it
|
||
# happens inside run_sync (thread pool) after the agent is constructed.
|
||
stream_task = None
|
||
stream_task = asyncio.create_task(self._run_agent_stream_consumer_task(stream_consumer_holder))
|
||
|
||
# Track this agent as running for this session (for interrupt support)
|
||
# We do this in a callback after the agent is created
|
||
tracking_task = asyncio.create_task(
|
||
self._run_agent_track_agent(session_key, run_generation, agent_holder)
|
||
)
|
||
|
||
_interrupt_detected = asyncio.Event() # shared with backup check
|
||
interrupt_monitor = asyncio.create_task(
|
||
self._run_agent_monitor_for_interrupt(
|
||
source,
|
||
session_key,
|
||
agent_holder,
|
||
_interrupt_detected,
|
||
streaming_tts_consumer_holder,
|
||
)
|
||
)
|
||
|
||
# Periodic "still working" notifications so the user knows the agent hasn't died. Config:
|
||
# agent.gateway_notify_interval or HERMES_AGENT_NOTIFY_INTERVAL env; default 180s.
|
||
_NOTIFY_INTERVAL_RAW = _float_env("HERMES_AGENT_NOTIFY_INTERVAL", 180)
|
||
_NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None
|
||
_long_running_mode = _display_surface_mode(
|
||
"long_running_notifications",
|
||
default=True,
|
||
allow_generic=True,
|
||
)
|
||
if _long_running_mode == "off":
|
||
_NOTIFY_INTERVAL = None
|
||
_notify_start = time.time()
|
||
_executor_task_holder: list = [None] # bound once the executor future exists (see below)
|
||
_notify_task = asyncio.create_task(
|
||
self._run_agent_notify_long_running(
|
||
disp,
|
||
source=source,
|
||
session_key=session_key,
|
||
agent_holder=agent_holder,
|
||
_executor_task_holder=_executor_task_holder,
|
||
_NOTIFY_INTERVAL=_NOTIFY_INTERVAL,
|
||
_long_running_mode=_long_running_mode,
|
||
_notify_start=_notify_start,
|
||
_status_thread_metadata=_status_thread_metadata,
|
||
_cleanup_progress=_cleanup_progress,
|
||
_cleanup_msg_ids=_cleanup_msg_ids,
|
||
)
|
||
)
|
||
|
||
try:
|
||
worker = self._run_agent_start_turn_worker(
|
||
turn_ctx, run_sync, agent_holder, session_id, session_key, run_generation,
|
||
)
|
||
_executor_task_holder[0] = worker.executor_task # read late by _notify_long_running
|
||
response = await self._run_agent_await_turn_worker(
|
||
worker,
|
||
source=source,
|
||
session_key=session_key,
|
||
agent_holder=agent_holder,
|
||
result_holder=result_holder,
|
||
tools_holder=tools_holder,
|
||
_interrupt_detected=_interrupt_detected,
|
||
interrupt_monitor=interrupt_monitor,
|
||
streaming_tts_consumer_holder=streaming_tts_consumer_holder,
|
||
_status_thread_metadata=_status_thread_metadata,
|
||
)
|
||
|
||
self._run_agent_evict_on_fallback(session_key, agent_holder, result_holder)
|
||
|
||
# Check if we were interrupted OR have a queued message (/queue).
|
||
result = result_holder[0]
|
||
adapter = self._adapter_for_source(source)
|
||
|
||
await self._run_agent_finalize_streaming_tts(
|
||
streaming_tts_consumer_holder, adapter, session_key, run_generation,
|
||
)
|
||
|
||
pending_event, pending = await self._run_agent_drain_pending(
|
||
result, adapter, source, session_key,
|
||
)
|
||
|
||
if pending_event or pending:
|
||
return await self._run_agent_queued_followup(
|
||
source=source,
|
||
adapter=adapter,
|
||
session_id=session_id,
|
||
session_key=session_key,
|
||
run_generation=run_generation,
|
||
_interrupt_depth=_interrupt_depth,
|
||
event_message_id=event_message_id,
|
||
context_prompt=context_prompt,
|
||
history=history,
|
||
pending=pending,
|
||
pending_event=pending_event,
|
||
response=response,
|
||
result=result,
|
||
result_holder=result_holder,
|
||
stream_consumer_holder=stream_consumer_holder,
|
||
stream_task=stream_task,
|
||
_status_thread_metadata=_status_thread_metadata,
|
||
)
|
||
finally:
|
||
await self._run_agent_cleanup_turn_tasks(
|
||
progress_task=progress_task,
|
||
log_task=log_task,
|
||
interrupt_monitor=interrupt_monitor,
|
||
_notify_task=_notify_task,
|
||
tracking_task=tracking_task,
|
||
stream_task=stream_task,
|
||
stream_consumer_holder=stream_consumer_holder,
|
||
streaming_tts_consumer_holder=streaming_tts_consumer_holder,
|
||
session_key=session_key,
|
||
run_generation=run_generation,
|
||
)
|
||
|
||
await self._run_agent_mark_streamed_delivery(
|
||
response, stream_consumer_holder, source, session_key,
|
||
)
|
||
self._run_agent_schedule_bubble_cleanup(
|
||
response,
|
||
_cleanup_progress,
|
||
_cleanup_adapter,
|
||
_cleanup_msg_ids,
|
||
source,
|
||
session_key,
|
||
run_generation,
|
||
)
|
||
|
||
return response
|