refactor(gateway): split _handle_active_session_busy_message and _prepare_inbound_message_text into helpers

This commit is contained in:
Teknium
2026-09-02 15:32:53 -07:00
parent 88b74d6ef0
commit e3bda94407
+459 -340
View File
@@ -9641,51 +9641,43 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return text
return (enriched_text or text).strip()
async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool:
# Authorization gate: the cold path (_handle_message) checks _is_user_authorized before
# creating a session; the busy path must enforce the same check, else unauthorized users in
# shared threads (Slack/Telegram/Discord) inject messages into a session they don't own.
if not self._is_user_authorized(event.source):
logger.warning(
"Dropping message from unauthorized user in active session: "
"user=%s (%s), platform=%s, session=%s",
event.source.user_id,
event.source.user_name,
event.source.platform.value if event.source.platform else "unknown",
session_key,
)
return True # handled (silently dropped); do not fall through
@staticmethod
def _busy_reply_to(event: MessageEvent, reply_anchor):
# Telegram DM topics anchor on the thread; other Telegram threads send unanchored.
return (
reply_anchor
if event.source.platform == Platform.TELEGRAM
and event.source.chat_type == "dm"
and event.source.thread_id
else (None if event.source.platform == Platform.TELEGRAM and event.source.thread_id else event.message_id)
)
effective_mode = self._effective_busy_input_mode(event.source)
async def _send_busy_drain_notice(self, event: MessageEvent, session_key: str, effective_mode: str) -> None:
"""Busy path while the gateway is restarting/stopping: queue (if allowed) and tell the user."""
adapter = self._adapter_for_source(event.source)
if not adapter:
return
# --- Draining case (gateway restarting/stopping) ---
if self._draining:
adapter = self._adapter_for_source(event.source)
if not adapter:
return True
reply_anchor = self._reply_anchor_for_event(event)
thread_meta = self._thread_metadata_for_source(event.source, reply_anchor)
if self._queue_during_drain_enabled(effective_mode):
self._queue_or_replace_pending_event(session_key, event)
message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back."
else:
message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
reply_anchor = self._reply_anchor_for_event(event)
thread_meta = self._thread_metadata_for_source(event.source, reply_anchor)
if self._queue_during_drain_enabled(effective_mode):
self._queue_or_replace_pending_event(session_key, event)
message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back."
else:
message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
await adapter._send_with_retry(
chat_id=event.source.chat_id,
content=message,
reply_to=self._busy_reply_to(event, reply_anchor),
metadata=thread_meta,
)
await adapter._send_with_retry(
chat_id=event.source.chat_id,
content=message,
reply_to=(
reply_anchor
if event.source.platform == Platform.TELEGRAM
and event.source.chat_type == "dm"
and event.source.thread_id
else (None if event.source.platform == Platform.TELEGRAM and event.source.thread_id else event.message_id)
),
metadata=thread_meta,
)
return True
async def _route_plaintext_approval_while_busy(self, event: MessageEvent, session_key: str) -> bool:
"""Route a bare "yes"/"no" to the approval handlers while a dangerous-command approval blocks.
Returns True when the message was consumed as an approval response.
"""
# Approval routing: while blocked on a dangerous-command approval, a bare "yes" must reach the
# approval handler, not be steered/queued/interrupted (else it queues behind a turn that can't
# start until the approval resolves -> auto-deny deadlock). Slash forms already bypass at the
@@ -9741,33 +9733,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"falling through to busy handling",
session_key, exc_info=True,
)
return False
# Normal busy case (agent actively running a task)
adapter = self._adapter_for_source(event.source)
if not adapter:
return False # let default path handle it
# Internal synthetic events (async-delegation / background-process completions) must never
# interrupt/steer: treated as user TEXT while busy, interrupt mode would abort the active turn;
# a completion surfaces as a NEW turn only when idle. Plugin events carry untrusted payload
# text, so queue them through the gateway FIFO (security metadata kept apart).
if getattr(event, "internal", False) and not event.allow_gateway_control:
self._queue_or_replace_pending_event(session_key, event)
return True
if getattr(event, "internal", False):
return False
_busy_state = self._peek_session_state(session_key)
running_agent = _busy_state.turn.agent if _busy_state else None
busy_text_mode = self._effective_busy_text_mode(event.source)
if (
event.message_type == MessageType.TEXT
and busy_text_mode == "queue"
and effective_mode != "steer"
):
return False
@dataclasses.dataclass
class _BusySteerOutcome:
effective_mode: str
demoted_for_subagents: bool
demoted_for_compression: bool
steered: bool
redirected: bool
async def _resolve_busy_steer_or_redirect(
self,
event: MessageEvent,
session_key: str,
effective_mode: str,
running_agent: Any,
) -> "GatewayRunner._BusySteerOutcome":
"""Apply interrupt->queue demotions, then attempt steer (steer mode) or redirect (interrupt mode)."""
# Steer mode injects mid-run via running_agent.steer(); fall back to queue (nothing lost) if the
# agent isn't running yet (sentinel), lacks steer(), or the payload is empty. interrupt()
# cascades to ``_active_children`` and aborts delegate_task work, so demote ``interrupt`` to
@@ -9842,80 +9825,68 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as exc:
logger.warning("Gateway redirect failed for session %s: %s", session_key, exc)
redirected = False
return self._BusySteerOutcome(
effective_mode=effective_mode,
demoted_for_subagents=demoted_for_subagents,
demoted_for_compression=demoted_for_compression,
steered=steered,
redirected=redirected,
)
# Queue as the next turn after the current run ends. Skip after a successful steer — the text
# is already in the run and must NOT replay. Use the FIFO helper, not raw
# merge_pending_message_event (merge_text=True newline-joins consecutive TEXT follow-ups into
# ONE turn); FIFO gives each text its own turn while keeping photo-burst / album merge for media.
if not steered and not redirected:
self._queue_or_replace_pending_event(session_key, event)
is_queue_mode = effective_mode == "queue"
is_steer_mode = effective_mode == "steer"
is_redirect_mode = effective_mode == "interrupt" and redirected
# Interrupt mode: abort in-flight tool calls; the agent loop exits at its next check point.
if (
effective_mode == "interrupt"
and not redirected
and running_agent
and running_agent is not _AGENT_PENDING_SENTINEL
):
try:
_interrupt_text = event.text
_media_urls = getattr(event, "media_urls", None) or []
if self._pending_event_audio_paths(event):
_interrupt_text, _ = await self._transcribe_and_echo_pending_voice(
event,
adapter,
event.source,
event.text or "",
log_context="Voice-busy-interrupt",
)
elif not _interrupt_text and _media_urls:
_interrupt_text = _build_media_placeholder(event)
running_agent.interrupt(_interrupt_text)
except Exception:
pass # don't let interrupt failure block the ack
# Disabled ack: skip sending, still process input. Checked before debounce so we never stamp a
# "last ack" timestamp for an ack that was not delivered.
busy_ack_enabled = os.environ.get("HERMES_GATEWAY_BUSY_ACK_ENABLED", "true").lower() == "true"
if not busy_ack_enabled:
logger.debug("Busy ack suppressed for session %s", session_key)
return True # input still processed, just no ack sent
# Debounce before the config-heavy display lookup: rapid follow-ups are still processed but
# shouldn't cost a config read just to learn no ack will be sent.
_BUSY_ACK_COOLDOWN = 30
now = time.time()
last_ack = _busy_state.turn.busy_ack_ts if _busy_state else 0
if now - last_ack < _BUSY_ACK_COOLDOWN:
return True # interrupt sent (if not queue), ack already delivered recently
from gateway.display_config import resolve_display_setting
platform_key = _platform_config_key(event.source.platform)
async def _interrupt_running_agent_for_busy_event(self, event: MessageEvent, adapter, running_agent) -> None:
"""Interrupt mode: abort in-flight tool calls; the agent loop exits at its next check point."""
try:
_interrupt_text = event.text
_media_urls = getattr(event, "media_urls", None) or []
if self._pending_event_audio_paths(event):
_interrupt_text, _ = await self._transcribe_and_echo_pending_voice(
event,
adapter,
event.source,
event.text or "",
log_context="Voice-busy-interrupt",
)
elif not _interrupt_text and _media_urls:
_interrupt_text = _build_media_placeholder(event)
running_agent.interrupt(_interrupt_text)
except Exception:
pass # don't let interrupt failure block the ack
def _busy_steer_ack_enabled(self, event: MessageEvent, session_key: str) -> bool:
# Steer mode already injected the text; some mobile chat setups want silent steering (like STT
# echo suppression) — keep the behavior, drop only the confirmation bubble.
if is_steer_mode:
steer_ack_env = os.environ.get("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED")
if steer_ack_env is not None:
steer_ack_enabled = steer_ack_env.strip().lower() in {"1", "true", "yes", "on"}
else:
steer_ack_enabled = bool(
resolve_display_setting(
_load_gateway_config(),
platform_key,
"busy_steer_ack_enabled",
True,
)
from gateway.display_config import resolve_display_setting
platform_key = _platform_config_key(event.source.platform)
steer_ack_env = os.environ.get("HERMES_GATEWAY_BUSY_STEER_ACK_ENABLED")
if steer_ack_env is not None:
steer_ack_enabled = steer_ack_env.strip().lower() in {"1", "true", "yes", "on"}
else:
steer_ack_enabled = bool(
resolve_display_setting(
_load_gateway_config(),
platform_key,
"busy_steer_ack_enabled",
True,
)
if not steer_ack_enabled:
logger.debug("Busy steer ack suppressed for session %s", session_key)
return True
)
if not steer_ack_enabled:
logger.debug("Busy steer ack suppressed for session %s", session_key)
return steer_ack_enabled
self._session_state(session_key).turn.busy_ack_ts = now
def _compose_busy_ack_message(
self,
event: MessageEvent,
now: float,
_busy_state,
running_agent: Any,
*,
is_steer_mode: bool,
is_queue_mode: bool,
is_redirect_mode: bool,
demoted_for_subagents: bool,
demoted_for_compression: bool,
) -> str:
from gateway.display_config import resolve_display_setting
# Mobile chat defaults keep the ack terse; iteration/tool detail stays in logs and can be opted
# in per platform via display.platforms.<platform>.busy_ack_detail.
@@ -10006,25 +9977,131 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG)
except Exception as _onb_err:
logger.debug("Failed to apply busy-input onboarding hint: %s", _onb_err)
return message
async def _send_busy_ack_reply(self, event: MessageEvent, adapter, message: str) -> None:
reply_anchor = self._reply_anchor_for_event(event)
thread_meta = self._thread_metadata_for_source(event.source, reply_anchor)
try:
await adapter._send_with_retry(
chat_id=event.source.chat_id,
content=message,
reply_to=(
reply_anchor
if event.source.platform == Platform.TELEGRAM
and event.source.chat_type == "dm"
and event.source.thread_id
else (None if event.source.platform == Platform.TELEGRAM and event.source.thread_id else event.message_id)
),
reply_to=self._busy_reply_to(event, reply_anchor),
metadata=thread_meta,
)
except Exception as e:
logger.debug("Failed to send busy-ack: %s", e)
async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool:
# Authorization gate: the cold path (_handle_message) checks _is_user_authorized before
# creating a session; the busy path must enforce the same check, else unauthorized users in
# shared threads (Slack/Telegram/Discord) inject messages into a session they don't own.
if not self._is_user_authorized(event.source):
logger.warning(
"Dropping message from unauthorized user in active session: "
"user=%s (%s), platform=%s, session=%s",
event.source.user_id,
event.source.user_name,
event.source.platform.value if event.source.platform else "unknown",
session_key,
)
return True # handled (silently dropped); do not fall through
effective_mode = self._effective_busy_input_mode(event.source)
# --- Draining case (gateway restarting/stopping) ---
if self._draining:
await self._send_busy_drain_notice(event, session_key, effective_mode)
return True
if await self._route_plaintext_approval_while_busy(event, session_key):
return True
# Normal busy case (agent actively running a task)
adapter = self._adapter_for_source(event.source)
if not adapter:
return False # let default path handle it
# Internal synthetic events (async-delegation / background-process completions) must never
# interrupt/steer: treated as user TEXT while busy, interrupt mode would abort the active turn;
# a completion surfaces as a NEW turn only when idle. Plugin events carry untrusted payload
# text, so queue them through the gateway FIFO (security metadata kept apart).
if getattr(event, "internal", False) and not event.allow_gateway_control:
self._queue_or_replace_pending_event(session_key, event)
return True
if getattr(event, "internal", False):
return False
_busy_state = self._peek_session_state(session_key)
running_agent = _busy_state.turn.agent if _busy_state else None
busy_text_mode = self._effective_busy_text_mode(event.source)
if (
event.message_type == MessageType.TEXT
and busy_text_mode == "queue"
and effective_mode != "steer"
):
return False
_steer = await self._resolve_busy_steer_or_redirect(event, session_key, effective_mode, running_agent)
effective_mode = _steer.effective_mode
demoted_for_subagents = _steer.demoted_for_subagents
demoted_for_compression = _steer.demoted_for_compression
steered = _steer.steered
redirected = _steer.redirected
# Queue as the next turn after the current run ends. Skip after a successful steer — the text
# is already in the run and must NOT replay. Use the FIFO helper, not raw
# merge_pending_message_event (merge_text=True newline-joins consecutive TEXT follow-ups into
# ONE turn); FIFO gives each text its own turn while keeping photo-burst / album merge for media.
if not steered and not redirected:
self._queue_or_replace_pending_event(session_key, event)
is_queue_mode = effective_mode == "queue"
is_steer_mode = effective_mode == "steer"
is_redirect_mode = effective_mode == "interrupt" and redirected
# Interrupt mode: abort in-flight tool calls; the agent loop exits at its next check point.
if (
effective_mode == "interrupt"
and not redirected
and running_agent
and running_agent is not _AGENT_PENDING_SENTINEL
):
await self._interrupt_running_agent_for_busy_event(event, adapter, running_agent)
# Disabled ack: skip sending, still process input. Checked before debounce so we never stamp a
# "last ack" timestamp for an ack that was not delivered.
busy_ack_enabled = os.environ.get("HERMES_GATEWAY_BUSY_ACK_ENABLED", "true").lower() == "true"
if not busy_ack_enabled:
logger.debug("Busy ack suppressed for session %s", session_key)
return True # input still processed, just no ack sent
# Debounce before the config-heavy display lookup: rapid follow-ups are still processed but
# shouldn't cost a config read just to learn no ack will be sent.
_BUSY_ACK_COOLDOWN = 30
now = time.time()
last_ack = _busy_state.turn.busy_ack_ts if _busy_state else 0
if now - last_ack < _BUSY_ACK_COOLDOWN:
return True # interrupt sent (if not queue), ack already delivered recently
if is_steer_mode and not self._busy_steer_ack_enabled(event, session_key):
return True
self._session_state(session_key).turn.busy_ack_ts = now
message = self._compose_busy_ack_message(
event,
now,
_busy_state,
running_agent,
is_steer_mode=is_steer_mode,
is_queue_mode=is_queue_mode,
is_redirect_mode=is_redirect_mode,
demoted_for_subagents=demoted_for_subagents,
demoted_for_compression=demoted_for_compression,
)
await self._send_busy_ack_reply(event, adapter, message)
return True
async def _drain_active_agents(
@@ -17503,37 +17580,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
logger.debug("Failed to restore one-turn model override", exc_info=True)
async def _prepare_inbound_message_text(
self,
*,
event: MessageEvent,
source: SessionSource,
history: List[Dict[str, Any]],
session_key: Optional[str] = None,
) -> Optional[str]:
"""Prepare inbound event text for the agent.
Shared by the normal inbound and queued follow-up paths so attribution, image enrichment,
STT, document notes, reply context and @ references behave the same. Side effect: buffers
per-session native image paths when the model supports native vision; the caller consumes
that buffer at ``run_conversation``. Empty list means the text vision path already ran.
"""
history = history or []
_pending_stt_prepared = hasattr(event, "_gateway_pending_stt_text")
message_text = (
getattr(event, "_gateway_pending_stt_text", None)
if _pending_stt_prepared
else event.text
) or ""
def _prefix_inbound_sender_context(self, event: MessageEvent, source: SessionSource, message_text: str) -> str:
"""Attribute the sender in shared multi-user sessions and prepend history-backfill channel context."""
_group_sessions_per_user = getattr(self.config, "group_sessions_per_user", True)
_thread_sessions_per_user = getattr(self.config, "thread_sessions_per_user", False)
# Prefer the caller's resolved session key so this write key matches the consume key at the
# run_conversation site; derive it here only for tests and legacy standalone callers.
session_key = session_key or self._session_key_for_source(source)
# Reset only this session's per-call buffer; other sessions may be
# concurrently preparing multimodal turns on the same runner.
self._consume_pending_native_image_paths(session_key)
_is_shared_multi_user = is_shared_multi_user_session(
source,
group_sessions_per_user=_group_sessions_per_user,
@@ -17559,15 +17609,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# only to the trigger message, not the backfill block.
if getattr(event, "channel_context", None):
message_text = f"{event.channel_context}\n\n[New message]\n{message_text}"
return message_text
# Declare at outer scope so the audio-file-paths handling block below
# remains safe when ``event.media_urls`` is empty (no inner block runs).
@staticmethod
def _classify_inbound_media(
event: MessageEvent, pending_stt_prepared: bool
) -> Tuple[list, list, list, list]:
"""Split ``event.media_urls`` into (image, STT-voice, audio-file, video) paths."""
image_paths: list[str] = []
audio_paths: list[str] = []
audio_file_paths: list[str] = []
video_paths: list[str] = []
if event.media_urls:
image_paths = []
audio_paths = []
for i, path in enumerate(event.media_urls):
mtype = event.media_types[i] if i < len(event.media_types) else ""
# Classify images per-attachment: trust this attachment's own MIME, and only honour
@@ -17581,87 +17635,96 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if _event_media_is_audio(event, i):
if event.message_type in {MessageType.AUDIO, MessageType.DOCUMENT}:
audio_file_paths.append(path)
elif not _pending_stt_prepared and _event_media_is_stt_input(event, i):
elif not pending_stt_prepared and _event_media_is_stt_input(event, i):
audio_paths.append(path)
if mtype.startswith("video/") or (not mtype and event.message_type == MessageType.VIDEO):
video_paths.append(path)
return image_paths, audio_paths, audio_file_paths, video_paths
if image_paths:
# Decide routing: native (attach pixels) vs text (vision_analyze pre-run + prepend
# description). See agent/image_routing.py. Offloaded to a thread: the decision does
# blocking network I/O (models.dev fetch on cache miss, Ollama /api/show probe) whose
# timeout would otherwise stall the whole gateway event loop.
_img_mode = await asyncio.to_thread(
self._decide_image_input_mode,
async def _enrich_inbound_images(
self, source: SessionSource, session_key: str, message_text: str, image_paths: list[str]
) -> str:
# Decide routing: native (attach pixels) vs text (vision_analyze pre-run + prepend
# description). See agent/image_routing.py. Offloaded to a thread: the decision does
# blocking network I/O (models.dev fetch on cache miss, Ollama /api/show probe) whose
# timeout would otherwise stall the whole gateway event loop.
_img_mode = await asyncio.to_thread(
self._decide_image_input_mode,
source=source,
session_key=session_key,
)
if _img_mode == "native":
# Defer attachment to the run_conversation call site.
self._session_state(
session_key
).persistent.native_image_paths = list(image_paths)
logger.info(
"Image routing: native (model supports vision). %d image(s) will be attached inline.",
len(image_paths),
)
else:
logger.info(
"Image routing: text (mode=%s). Pre-analyzing %d image(s) via vision_analyze.",
_img_mode, len(image_paths),
)
# Vision enrichment runs before AIAgent.run_conversation(),
# so bind this session's resolved runtime explicitly rather
# than consulting process-global compatibility mirrors.
vision_runtime = None
try:
turn_model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
)
if _img_mode == "native":
# Defer attachment to the run_conversation call site.
self._session_state(
session_key
).persistent.native_image_paths = list(image_paths)
logger.info(
"Image routing: native (model supports vision). %d image(s) will be attached inline.",
len(image_paths),
)
else:
logger.info(
"Image routing: text (mode=%s). Pre-analyzing %d image(s) via vision_analyze.",
_img_mode, len(image_paths),
)
# Vision enrichment runs before AIAgent.run_conversation(),
# so bind this session's resolved runtime explicitly rather
# than consulting process-global compatibility mirrors.
vision_runtime = None
try:
turn_model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
)
vision_runtime = dict(runtime_kwargs or {})
vision_runtime["model"] = turn_model
except Exception:
logger.debug(
"vision enrichment: session runtime resolution failed",
exc_info=True,
)
from agent.auxiliary_client import scoped_runtime_main
with scoped_runtime_main(vision_runtime):
message_text = await self._enrich_message_with_vision(
message_text,
image_paths,
)
if audio_paths:
message_text, _successful_transcripts = await self._enrich_message_with_transcription(
message_text,
audio_paths,
vision_runtime = dict(runtime_kwargs or {})
vision_runtime["model"] = turn_model
except Exception:
logger.debug(
"vision enrichment: session runtime resolution failed",
exc_info=True,
)
# Echo each successful transcript back to the user immediately when configured. Lets
# users verify STT quality in real-time, while allowing quiet STT for users who only
# want the agent to receive the transcription.
if _successful_transcripts and self._should_echo_stt_transcripts():
_echo_adapter = self._adapter_for_source(source)
_echo_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
if _echo_adapter:
for _tx in _successful_transcripts:
try:
await _echo_adapter.send(
source.chat_id,
f'🎙️ "{_tx}"',
metadata=_echo_meta,
)
except Exception as _echo_exc:
logger.debug(
"Transcript echo failed (non-fatal): %s", _echo_exc,
)
# On transcription failure, do NOT send a hardcoded notice here: that bypassed the
# LLM and produced two replies (one pre-canned, TTS'd in the wrong language).
# Enrichment leaves a single neutral marker so the LLM gives one localized reply.
from agent.auxiliary_client import scoped_runtime_main
with scoped_runtime_main(vision_runtime):
message_text = await self._enrich_message_with_vision(
message_text,
image_paths,
)
return message_text
async def _enrich_inbound_voice(
self, event: MessageEvent, source: SessionSource, message_text: str, audio_paths: list[str]
) -> str:
message_text, _successful_transcripts = await self._enrich_message_with_transcription(
message_text,
audio_paths,
)
# Echo each successful transcript back to the user immediately when configured. Lets
# users verify STT quality in real-time, while allowing quiet STT for users who only
# want the agent to receive the transcription.
if _successful_transcripts and self._should_echo_stt_transcripts():
_echo_adapter = self._adapter_for_source(source)
_echo_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
if _echo_adapter:
for _tx in _successful_transcripts:
try:
await _echo_adapter.send(
source.chat_id,
f'🎙️ "{_tx}"',
metadata=_echo_meta,
)
except Exception as _echo_exc:
logger.debug(
"Transcript echo failed (non-fatal): %s", _echo_exc,
)
# On transcription failure, do NOT send a hardcoded notice here: that bypassed the
# LLM and produced two replies (one pre-canned, TTS'd in the wrong language).
# Enrichment leaves a single neutral marker so the LLM gives one localized reply.
return message_text
@staticmethod
def _prepend_inbound_media_file_notes(message_text: str, audio_file_paths: list[str], video_paths: list[str]) -> str:
if audio_file_paths:
from tools.credential_files import to_agent_visible_cache_path as _to_agent_path
for _apath in audio_file_paths:
@@ -17699,7 +17762,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
f"with it if their intent is genuinely unclear.]"
)
message_text = f"{_note}\n\n{message_text}"
return message_text
@staticmethod
def _prepend_inbound_document_notes(event: MessageEvent, message_text: str) -> str:
if event.media_urls:
import mimetypes as _mimetypes
from tools.credential_files import to_agent_visible_cache_path
@@ -17746,7 +17812,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
content_inlined=inline_flag is not False,
)
message_text = f"{context_note}\n\n{message_text}"
return message_text
@staticmethod
def _prepend_inbound_reply_context(event: MessageEvent, source: SessionSource, message_text: str) -> str:
# Discord: surface the triggering message id per-turn on the user message rather than in the
# cached system prompt. message_id changes every turn, so baking it into
# build_session_context_prompt() would bust the agent-cache signature and rebuild the
@@ -17776,111 +17845,161 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
else:
message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}'
return message_text
async def _expand_inbound_context_references(
self, source: SessionSource, session_key: str, message_text: str
) -> Optional[str]:
"""Expand ``@`` context references; returns None when the injection was refused (user notified)."""
try:
from agent.context_references import preprocess_context_references_async
from agent.model_metadata import get_model_context_length_async
if "@" in message_text:
try:
from agent.context_references import preprocess_context_references_async
from agent.model_metadata import get_model_context_length_async
from tools.terminal_scope import terminal_env as _ts_env
except ImportError:
_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
else:
_msg_cwd = _ts_env("TERMINAL_CWD", os.path.expanduser("~"))
_msg_config_ctx = None
_msg_cfg = None
_msg_model_cfg = {}
_msg_custom_providers = []
try:
_msg_cfg = _load_gateway_config()
_msg_model_cfg = _msg_cfg.get("model", {})
if isinstance(_msg_model_cfg, dict):
_msg_raw_ctx = _msg_model_cfg.get("context_length")
if _msg_raw_ctx is not None:
_msg_config_ctx = int(_msg_raw_ctx)
try:
from tools.terminal_scope import terminal_env as _ts_env
except ImportError:
_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
else:
_msg_cwd = _ts_env("TERMINAL_CWD", os.path.expanduser("~"))
from hermes_cli.config import get_compatible_custom_providers
_msg_custom_providers = get_compatible_custom_providers(_msg_cfg)
except Exception:
_msg_custom_providers = _msg_cfg.get("custom_providers") or []
except Exception:
pass
# Resolve the session's actual model/provider/base_url as the hygiene compression
# block does; GatewayRunner has no self._model/self._base_url (AttributeError,
# silently caught below).
_msg_model, _msg_runtime = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=_msg_cfg,
)
_msg_base_url = _msg_runtime.get("base_url") or ""
# A global model.context_length belongs to the configured
# model, not a session /model or channel override. Prefer a
# matching per-custom-provider model limit when available.
_msg_configured_model = (
_msg_model_cfg.get("default") or _msg_model_cfg.get("model")
if isinstance(_msg_model_cfg, dict)
else _msg_model_cfg
)
if _msg_model != _msg_configured_model:
_msg_config_ctx = None
_msg_cfg = None
_msg_model_cfg = {}
_msg_custom_providers = []
if _msg_config_ctx is not None and isinstance(_msg_model_cfg, dict):
try:
_msg_cfg = _load_gateway_config()
_msg_model_cfg = _msg_cfg.get("model", {})
if isinstance(_msg_model_cfg, dict):
_msg_raw_ctx = _msg_model_cfg.get("context_length")
if _msg_raw_ctx is not None:
_msg_config_ctx = int(_msg_raw_ctx)
try:
from hermes_cli.config import get_compatible_custom_providers
from hermes_cli.route_identity import should_clear_context_pin_async
_msg_custom_providers = get_compatible_custom_providers(_msg_cfg)
except Exception:
_msg_custom_providers = _msg_cfg.get("custom_providers") or []
if await should_clear_context_pin_async(
None, # model match already checked above
None,
_msg_model_cfg.get("base_url"),
_msg_base_url,
_msg_model_cfg.get("provider"),
_msg_runtime.get("provider"),
):
_msg_config_ctx = None
except Exception:
_msg_config_ctx = None
if _msg_custom_providers and _msg_base_url:
try:
from hermes_cli.config import get_custom_provider_context_length
_msg_custom_ctx = get_custom_provider_context_length(
model=_msg_model,
base_url=_msg_base_url,
custom_providers=_msg_custom_providers,
)
if _msg_custom_ctx:
_msg_config_ctx = _msg_custom_ctx
except Exception:
pass
# Resolve the session's actual model/provider/base_url as the hygiene compression
# block does; GatewayRunner has no self._model/self._base_url (AttributeError,
# silently caught below).
_msg_model, _msg_runtime = self._resolve_session_agent_runtime(
source=source,
session_key=session_key,
user_config=_msg_cfg,
)
_msg_base_url = _msg_runtime.get("base_url") or ""
# A global model.context_length belongs to the configured
# model, not a session /model or channel override. Prefer a
# matching per-custom-provider model limit when available.
_msg_configured_model = (
_msg_model_cfg.get("default") or _msg_model_cfg.get("model")
if isinstance(_msg_model_cfg, dict)
else _msg_model_cfg
)
if _msg_model != _msg_configured_model:
_msg_config_ctx = None
if _msg_config_ctx is not None and isinstance(_msg_model_cfg, dict):
try:
from hermes_cli.route_identity import should_clear_context_pin_async
_msg_ctx_len = await get_model_context_length_async(
_msg_model,
base_url=_msg_base_url,
api_key=_msg_runtime.get("api_key") or "",
config_context_length=_msg_config_ctx,
provider=_msg_runtime.get("provider") or "",
custom_providers=_msg_custom_providers,
)
_ctx_result = await preprocess_context_references_async(
message_text,
cwd=_msg_cwd,
context_length=_msg_ctx_len,
allowed_root=_msg_cwd,
)
if _ctx_result.blocked:
_adapter = self._adapter_for_source(source)
if _adapter:
await _adapter.send(
source.chat_id,
"\n".join(_ctx_result.warnings) or "Context injection refused.",
)
return None
if _ctx_result.expanded:
message_text = _ctx_result.message
except Exception as exc:
logger.warning("@ context reference expansion failed: %s", exc)
logger.debug("@ context reference expansion failure detail", exc_info=True)
return message_text
if await should_clear_context_pin_async(
None, # model match already checked above
None,
_msg_model_cfg.get("base_url"),
_msg_base_url,
_msg_model_cfg.get("provider"),
_msg_runtime.get("provider"),
):
_msg_config_ctx = None
except Exception:
_msg_config_ctx = None
if _msg_custom_providers and _msg_base_url:
try:
from hermes_cli.config import get_custom_provider_context_length
async def _prepare_inbound_message_text(
self,
*,
event: MessageEvent,
source: SessionSource,
history: List[Dict[str, Any]],
session_key: Optional[str] = None,
) -> Optional[str]:
"""Prepare inbound event text for the agent.
_msg_custom_ctx = get_custom_provider_context_length(
model=_msg_model,
base_url=_msg_base_url,
custom_providers=_msg_custom_providers,
)
if _msg_custom_ctx:
_msg_config_ctx = _msg_custom_ctx
except Exception:
pass
_msg_ctx_len = await get_model_context_length_async(
_msg_model,
base_url=_msg_base_url,
api_key=_msg_runtime.get("api_key") or "",
config_context_length=_msg_config_ctx,
provider=_msg_runtime.get("provider") or "",
custom_providers=_msg_custom_providers,
)
_ctx_result = await preprocess_context_references_async(
message_text,
cwd=_msg_cwd,
context_length=_msg_ctx_len,
allowed_root=_msg_cwd,
)
if _ctx_result.blocked:
_adapter = self._adapter_for_source(source)
if _adapter:
await _adapter.send(
source.chat_id,
"\n".join(_ctx_result.warnings) or "Context injection refused.",
)
return None
if _ctx_result.expanded:
message_text = _ctx_result.message
except Exception as exc:
logger.warning("@ context reference expansion failed: %s", exc)
logger.debug("@ context reference expansion failure detail", exc_info=True)
Shared by the normal inbound and queued follow-up paths so attribution, image enrichment,
STT, document notes, reply context and @ references behave the same. Side effect: buffers
per-session native image paths when the model supports native vision; the caller consumes
that buffer at ``run_conversation``. Empty list means the text vision path already ran.
"""
history = history or []
_pending_stt_prepared = hasattr(event, "_gateway_pending_stt_text")
message_text = (
getattr(event, "_gateway_pending_stt_text", None)
if _pending_stt_prepared
else event.text
) or ""
# Prefer the caller's resolved session key so this write key matches the consume key at the
# run_conversation site; derive it here only for tests and legacy standalone callers.
session_key = session_key or self._session_key_for_source(source)
# Reset only this session's per-call buffer; other sessions may be
# concurrently preparing multimodal turns on the same runner.
self._consume_pending_native_image_paths(session_key)
message_text = self._prefix_inbound_sender_context(event, source, message_text)
image_paths, audio_paths, audio_file_paths, video_paths = self._classify_inbound_media(
event, _pending_stt_prepared
)
if image_paths:
message_text = await self._enrich_inbound_images(source, session_key, message_text, image_paths)
if audio_paths:
message_text = await self._enrich_inbound_voice(event, source, message_text, audio_paths)
message_text = self._prepend_inbound_media_file_notes(message_text, audio_file_paths, video_paths)
message_text = self._prepend_inbound_document_notes(event, message_text)
message_text = self._prepend_inbound_reply_context(event, source, message_text)
if "@" in message_text:
message_text = await self._expand_inbound_context_references(source, session_key, message_text)
if message_text is None:
return None
return message_text