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.
2403 lines
118 KiB
Python
2403 lines
118 KiB
Python
"""Per-turn callback runner (progress/status/voice/run_sync) for the gateway agent turn.
|
||
|
||
Split out of ``gateway/run.py``; ``TurnRunner`` owns the per-turn callbacks/closures ``GatewayRunner._run_agent_inner`` binds.
|
||
``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 queue
|
||
import re
|
||
import threading
|
||
import time
|
||
from agent.replay_cleanup import strip_stale_dangerous_confirmations
|
||
from contextlib import suppress
|
||
from datetime import datetime
|
||
from gateway.config import Platform
|
||
from gateway.media_repair import repair_explicit_computer_use_media_paths
|
||
from gateway.platforms.base import BasePlatformAdapter
|
||
from gateway.turn_context import TurnContext
|
||
from hermes_cli.config import cfg_get
|
||
from typing import Any, Dict, List, Optional
|
||
from utils import is_truthy_value
|
||
|
||
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 TurnRunner:
|
||
"""Per-turn collaborator carrying ``GatewayRunner._run_agent_inner``'s tool-progress callbacks.
|
||
|
||
Module-global references (logger, cfg_get, BasePlatformAdapter, ...) resolve in this module.
|
||
"""
|
||
|
||
def __init__(self, runner: "GatewayRunner", ctx: TurnContext) -> None:
|
||
self._runner = runner
|
||
self._ctx = ctx
|
||
|
||
def progress_callback(self, event_type: str, tool_name: str = None, preview: str = None, args: dict = None, **kwargs):
|
||
"""Callback invoked by agent on tool lifecycle events."""
|
||
from gateway.run import _hermes_home, _load_gateway_config, safe_schedule_threadsafe
|
||
ctx = self._ctx
|
||
# Failed subagent → one clean user-facing notice, handled FIRST, before every progress-queue
|
||
# gate: platforms with tool_progress off must still hear about a dead delegation. Only
|
||
# terminal failure statuses render (same notice rail as credit warnings); success/interrupt
|
||
# stay quiet.
|
||
if event_type == "subagent.complete":
|
||
_sub_status = kwargs.get("status")
|
||
try:
|
||
from tools.delegate_tool import (
|
||
SUBAGENT_FAILURE_STATUSES,
|
||
format_subagent_failure_line,
|
||
)
|
||
if _sub_status in SUBAGENT_FAILURE_STATUSES and ctx._run_still_current():
|
||
_line = format_subagent_failure_line(
|
||
kwargs.get("goal"),
|
||
_sub_status,
|
||
error=kwargs.get("summary") or preview,
|
||
duration_seconds=kwargs.get("duration_seconds"),
|
||
)
|
||
safe_schedule_threadsafe(
|
||
self._runner._deliver_platform_notice(ctx.source, _line),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="subagent failure notice scheduling error",
|
||
)
|
||
except Exception:
|
||
logger.debug("subagent failure notice failed", exc_info=True)
|
||
return
|
||
# Live status line (Slack assistant status): stash the tool phrase on the adapter; the
|
||
# _keep_typing refresh renders it. Plain dict write, safe from the sync worker thread.
|
||
if (
|
||
ctx._live_status_adapter is not None
|
||
and ctx._live_status_mode != "off"
|
||
and tool_name != "_thinking"
|
||
):
|
||
try:
|
||
if event_type == "tool.started" and tool_name and ctx._run_still_current():
|
||
from agent.display import build_status_phrase
|
||
_phrase = build_status_phrase(
|
||
tool_name,
|
||
args if ctx._live_status_mode == "full" else None,
|
||
)
|
||
ctx._live_status_adapter.set_status_text(ctx.source.chat_id, _phrase)
|
||
elif event_type == "tool.completed":
|
||
# Between tools the model is genuinely "thinking"
|
||
# again — revert to the static default.
|
||
ctx._live_status_adapter.set_status_text(ctx.source.chat_id, None)
|
||
except Exception as _ls_err:
|
||
logger.debug("live status update failed: %s", _ls_err)
|
||
# "log" mode: append tool.started lines to the log queue, silent in chat. Handled before
|
||
# the progress_queue guard because log mode runs without a chat progress queue.
|
||
if ctx.log_queue is not None:
|
||
if event_type == "tool.started" and tool_name and tool_name != "_thinking":
|
||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
preview_str = f' "{preview}"' if preview else ""
|
||
ctx.log_queue.put(f"{ts} {tool_name}:{preview_str}".rstrip())
|
||
if not ctx.progress_queue:
|
||
return
|
||
if not ctx.progress_queue or not ctx._run_still_current():
|
||
return
|
||
|
||
# First-touch onboarding: the first time a tool exceeds _LONG_TOOL_THRESHOLD_S while
|
||
# streaming every tool (progress_mode == "all"), append a one-time /verbose hint.
|
||
if event_type == "tool.completed" and not ctx.long_tool_hint_fired[0]:
|
||
try:
|
||
duration = kwargs.get("duration") or 0
|
||
if duration >= ctx._LONG_TOOL_THRESHOLD_S and ctx.progress_mode == "all":
|
||
from agent.onboarding import (
|
||
TOOL_PROGRESS_FLAG,
|
||
is_seen,
|
||
mark_seen,
|
||
tool_progress_hint_gateway,
|
||
)
|
||
_cfg = _load_gateway_config()
|
||
gate_on = is_truthy_value(
|
||
cfg_get(_cfg, "display", "tool_progress_command"),
|
||
default=False,
|
||
)
|
||
if gate_on and not is_seen(_cfg, TOOL_PROGRESS_FLAG):
|
||
ctx.long_tool_hint_fired[0] = True
|
||
ctx.progress_queue.put(tool_progress_hint_gateway())
|
||
mark_seen(_hermes_home / "config.yaml", TOOL_PROGRESS_FLAG)
|
||
except Exception as _hint_err:
|
||
logger.debug("tool-progress onboarding hint failed: %s", _hint_err)
|
||
return
|
||
|
||
# "_thinking" is assistant scratch text between tool calls. It is never ordinary tool
|
||
# progress: only relay it when the platform explicitly opted into thinking_progress.
|
||
if event_type == "_thinking" or tool_name == "_thinking":
|
||
if not ctx._thinking_enabled:
|
||
return
|
||
thinking_text = preview if tool_name == "_thinking" else tool_name
|
||
msg = f"💬 {thinking_text}" if thinking_text else None
|
||
if msg:
|
||
ctx.progress_queue.put(msg)
|
||
return
|
||
|
||
# Native task cards consume the ID-bearing tool_start/tool_complete callbacks instead;
|
||
# name-correlated text events would duplicate cards and mispair concurrent same-tool calls.
|
||
if ctx._native_slack_task_cards and event_type in {
|
||
"tool.started",
|
||
"tool.completed",
|
||
}:
|
||
return
|
||
|
||
# If tool_progress is off, only _thinking passes through (above).
|
||
# Regular tool calls are suppressed.
|
||
if not ctx.tool_progress_enabled:
|
||
return
|
||
|
||
# Only act on tool.started events (ignore tool.completed, reasoning.available, etc.)
|
||
if event_type not in {"tool.started",}:
|
||
return
|
||
|
||
# Never render a progress bubble for clarify: send_clarify IS the user-facing rendering, so
|
||
# a bubble is duplication, and verbose mode would dump the raw tool-call args JSON, which
|
||
# (progress queue drains on a background task) lands right under the rendered prompt.
|
||
if tool_name == "clarify":
|
||
return
|
||
|
||
# Suppress tool-progress bubbles once the user sent `stop`: N parallel tool calls fire N
|
||
# "tool.started" events before the interrupt check, so a late `stop` would still render
|
||
# all N bubbles. (agent_holder[0] is the shared agent handle across nested scopes.)
|
||
try:
|
||
_agent_for_interrupt = ctx.agent_holder[0] if ctx.agent_holder else None
|
||
if _agent_for_interrupt is not None and getattr(
|
||
_agent_for_interrupt, "is_interrupted", False
|
||
):
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
# "new" mode: only report when tool changes
|
||
if ctx.progress_mode == "new" and tool_name == ctx.last_tool[0]:
|
||
return
|
||
ctx.last_tool[0] = tool_name
|
||
|
||
# Build progress message with primary argument preview
|
||
from agent.display import get_tool_emoji
|
||
emoji = get_tool_emoji(tool_name, default="⚙️")
|
||
|
||
# Markdown platforms (``supports_code_blocks``) fence terminal commands; plain-text ones
|
||
# keep the compact `terminal: "cmd…"` line. No language tag: Slack mrkdwn renders it as a
|
||
# literal first code line. Verbose shows the FULL command; "all"/"new" fence but truncate to
|
||
# one line capped at ``tool_preview_length`` (default 40), the non-terminal preview budget.
|
||
_code_block_full = None
|
||
_code_block_short = None
|
||
try:
|
||
_progress_adapter = self._runner._adapter_for_source(ctx.source)
|
||
except Exception:
|
||
_progress_adapter = None
|
||
if (
|
||
getattr(_progress_adapter, "supports_code_blocks", False)
|
||
and tool_name == "terminal"
|
||
and isinstance(args, dict)
|
||
and isinstance(args.get("command"), str)
|
||
and args["command"].strip()
|
||
):
|
||
from agent.display import get_tool_preview_max_len
|
||
_cmd_full = args["command"].rstrip()
|
||
# Consecutive terminal calls drop the repeated "💻 terminal" header so back-to-back
|
||
# commands render as adjacent code blocks under one header.
|
||
_block_header = (
|
||
"" if ctx.last_was_terminal_block[0] else f"{emoji} {tool_name}\n"
|
||
)
|
||
_code_block_full = f"{_block_header}```\n{_cmd_full}\n```"
|
||
# Single-line, capped preview for non-verbose modes.
|
||
_pl = get_tool_preview_max_len()
|
||
_cap = _pl if _pl > 0 else 40
|
||
_lines = _cmd_full.splitlines()
|
||
_cmd_short = _lines[0] if _lines else _cmd_full
|
||
_multiline = len(_lines) > 1
|
||
if len(_cmd_short) > _cap:
|
||
_cmd_short = _cmd_short[:_cap - 3] + "..."
|
||
elif _multiline:
|
||
_cmd_short = _cmd_short + " ..."
|
||
_code_block_short = f"{_block_header}```\n{_cmd_short}\n```"
|
||
|
||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||
if ctx.progress_mode == "verbose":
|
||
if _code_block_full is not None:
|
||
ctx.last_was_terminal_block[0] = True
|
||
ctx.progress_queue.put(_code_block_full)
|
||
return
|
||
ctx.last_was_terminal_block[0] = False
|
||
if args:
|
||
from agent.display import get_tool_preview_max_len
|
||
_pl = get_tool_preview_max_len()
|
||
args_str = json.dumps(args, ensure_ascii=False, default=str)
|
||
# tool_preview_length 0 (default) = no truncation in verbose mode; the user asked
|
||
# for full detail and platform message-length limits handle the rest.
|
||
if _pl > 0 and len(args_str) > _pl:
|
||
args_str = args_str[:_pl - 3] + "..."
|
||
msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}"
|
||
elif preview:
|
||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||
else:
|
||
msg = f"{emoji} {tool_name}..."
|
||
ctx.progress_queue.put(msg)
|
||
return
|
||
|
||
# "all" / "new" modes: short preview capped by tool_preview_length (default 40; gateway
|
||
# messages persist, unlike CLI spinners). Markdown terminal commands use the fence above.
|
||
if _code_block_short is not None:
|
||
msg = _code_block_short
|
||
ctx.last_was_terminal_block[0] = True
|
||
elif preview:
|
||
from agent.display import (
|
||
get_tool_preview_max_len,
|
||
get_tool_verb,
|
||
prepare_tool_preview,
|
||
tool_verb_connector,
|
||
verb_drops_preview,
|
||
)
|
||
_pl = get_tool_preview_max_len()
|
||
_cap = _pl if _pl > 0 else 40
|
||
_prepared_preview = prepare_tool_preview(
|
||
tool_name,
|
||
args,
|
||
fallback=preview,
|
||
max_len=_cap,
|
||
)
|
||
if _progress_adapter is not None:
|
||
preview = _progress_adapter.format_tool_preview(_prepared_preview)
|
||
else:
|
||
preview = _prepared_preview.text
|
||
# Friendly labels: human-phrased line for built-in tools ("🔍 Searching the web for ...")
|
||
# by prefixing the verb onto the computed preview, so the command/url/query is kept.
|
||
_verb = get_tool_verb(tool_name)
|
||
if _verb:
|
||
if verb_drops_preview(tool_name):
|
||
msg = f"{emoji} {_verb}"
|
||
else:
|
||
msg = f"{emoji} {_verb}{tool_verb_connector(tool_name)}{preview}"
|
||
else:
|
||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||
ctx.last_was_terminal_block[0] = False
|
||
else:
|
||
msg = f"{emoji} {tool_name}..."
|
||
ctx.last_was_terminal_block[0] = False
|
||
|
||
# Dedup consecutive identical progress messages (common with execute_code: same
|
||
# boilerplate imports → identical previews).
|
||
if msg == ctx.last_progress_msg[0]:
|
||
ctx.repeat_count[0] += 1
|
||
# Native-stream-progress routing: dedup updates the last line
|
||
# in the overlay rather than sending a queue signal.
|
||
_sc = ctx.stream_consumer_holder[0] if ctx.stream_consumer_holder else None
|
||
if _sc is not None and getattr(_sc, "accepts_tool_progress", False):
|
||
# Replace the last progress line with the dedup version
|
||
_sc.on_tool_progress(f"{msg} (×{ctx.repeat_count[0] + 1})")
|
||
return
|
||
# Update the last line in progress_lines with a counter
|
||
# via a special "dedup" queue message.
|
||
ctx.progress_queue.put(("__dedup__", msg, ctx.repeat_count[0]))
|
||
return
|
||
ctx.last_progress_msg[0] = msg
|
||
ctx.repeat_count[0] = 0
|
||
|
||
# If the stream consumer is active with native streaming, inject progress into the stream
|
||
# bubble instead of the separate progress queue.
|
||
_sc = ctx.stream_consumer_holder[0] if ctx.stream_consumer_holder else None
|
||
if _sc is not None and getattr(_sc, "accepts_tool_progress", False):
|
||
_sc.on_tool_progress(msg)
|
||
return
|
||
|
||
ctx.progress_queue.put(msg)
|
||
|
||
async def _send_native_task_card_progress(self, adapter) -> None:
|
||
"""Drain the progress queue into Slack-native plan/task cards.
|
||
|
||
On any native failure, fall back to an editable in-thread message so progress stays live.
|
||
"""
|
||
ctx = self._ctx
|
||
tasks: Dict[str, Dict[str, str]] = {}
|
||
task_order: List[str] = []
|
||
fallback_msg_id: Optional[str] = None
|
||
native_failed = False
|
||
anonymous_seq = 0
|
||
|
||
def _compact(value: Any, limit: int = 120) -> str:
|
||
text = re.sub(r"\s+", " ", str(value or "")).strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - 3].rstrip() + "..."
|
||
|
||
def _visible_tasks() -> List[Dict[str, str]]:
|
||
return [tasks[task_id] for task_id in task_order[-8:]]
|
||
|
||
def _fallback_text() -> str:
|
||
labels = {
|
||
"in_progress": "running",
|
||
"complete": "complete",
|
||
"error": "error",
|
||
}
|
||
lines = [
|
||
f"- {task['title']} - {labels.get(task['status'], task['status'])}"
|
||
for task in _visible_tasks()
|
||
]
|
||
return "Hermes is working\n" + "\n".join(lines)
|
||
|
||
def _apply_native_event(raw: Any) -> bool:
|
||
nonlocal anonymous_seq
|
||
if not isinstance(raw, dict):
|
||
return False
|
||
event_type = raw.get("type")
|
||
if event_type not in {"tool.started", "tool.completed"}:
|
||
return False
|
||
call_id = str(raw.get("tool_call_id") or "")
|
||
if not call_id:
|
||
anonymous_seq += 1
|
||
call_id = f"anonymous_{anonymous_seq}"
|
||
tool_name = str(raw.get("tool_name") or "tool")
|
||
|
||
if event_type == "tool.started":
|
||
title = tool_name
|
||
preview = _compact(raw.get("preview"), 64)
|
||
if preview:
|
||
title = f"{tool_name} - {preview}"
|
||
if call_id not in tasks:
|
||
task_order.append(call_id)
|
||
tasks[call_id] = {
|
||
"id": call_id,
|
||
"title": _compact(title),
|
||
"status": "in_progress",
|
||
}
|
||
return True
|
||
|
||
task = tasks.get(call_id)
|
||
if task is None:
|
||
# Completion-only events are rare but valid on some runtimes; keep their real ID
|
||
# instead of guessing a same-name pending call.
|
||
task = {
|
||
"id": call_id,
|
||
"title": _compact(tool_name),
|
||
"status": "in_progress",
|
||
}
|
||
tasks[call_id] = task
|
||
task_order.append(call_id)
|
||
task["status"] = "error" if raw.get("is_error") else "complete"
|
||
return True
|
||
|
||
async def _send_or_edit_fallback() -> None:
|
||
nonlocal fallback_msg_id
|
||
text = _fallback_text()
|
||
if fallback_msg_id:
|
||
result = await adapter.edit_message(
|
||
chat_id=ctx.source.chat_id,
|
||
message_id=fallback_msg_id,
|
||
content=text,
|
||
metadata=ctx._progress_metadata,
|
||
)
|
||
if getattr(result, "success", False):
|
||
return
|
||
result = await adapter.send(
|
||
chat_id=ctx.source.chat_id,
|
||
content=text,
|
||
reply_to=ctx._progress_reply_to,
|
||
metadata=ctx._progress_metadata,
|
||
)
|
||
if getattr(result, "success", False) and getattr(
|
||
result, "message_id", None
|
||
):
|
||
fallback_msg_id = str(result.message_id)
|
||
if ctx._cleanup_progress:
|
||
ctx._cleanup_msg_ids.append(fallback_msg_id)
|
||
|
||
async def _publish_native_progress() -> None:
|
||
nonlocal native_failed
|
||
if not tasks:
|
||
return
|
||
if not native_failed:
|
||
result = await adapter.send_native_task_card_progress(
|
||
chat_id=ctx.source.chat_id,
|
||
tasks=_visible_tasks(),
|
||
title="Hermes is working",
|
||
reply_to=ctx._progress_reply_to,
|
||
metadata=ctx._progress_metadata,
|
||
fallback_text=_fallback_text(),
|
||
)
|
||
if getattr(result, "success", False):
|
||
return
|
||
native_failed = True
|
||
logger.warning(
|
||
"Slack native task-card progress failed; falling back "
|
||
"to an editable text update: %s",
|
||
getattr(result, "error", "unknown error"),
|
||
)
|
||
# Once the native rail fails, every later lifecycle event
|
||
# edits the same fallback message so progress remains live.
|
||
await _send_or_edit_fallback()
|
||
|
||
def _drain_native_queue() -> bool:
|
||
changed = False
|
||
while True:
|
||
try:
|
||
changed = _apply_native_event(
|
||
ctx.progress_queue.get_nowait()
|
||
) or changed
|
||
except queue.Empty:
|
||
return changed
|
||
except Exception:
|
||
logger.debug(
|
||
"Slack native progress queue drain failed",
|
||
exc_info=True,
|
||
)
|
||
return changed
|
||
|
||
def _agent_interrupted() -> bool:
|
||
try:
|
||
_agent = ctx.agent_holder[0] if ctx.agent_holder else None
|
||
return bool(
|
||
_agent is not None and getattr(_agent, "is_interrupted", False)
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
try:
|
||
while True:
|
||
if not ctx._run_still_current():
|
||
return
|
||
try:
|
||
raw = ctx.progress_queue.get_nowait()
|
||
except queue.Empty:
|
||
await asyncio.sleep(0.1)
|
||
continue
|
||
|
||
if _agent_interrupted():
|
||
continue
|
||
|
||
if _apply_native_event(raw):
|
||
await _publish_native_progress()
|
||
except asyncio.CancelledError:
|
||
if _drain_native_queue() and ctx._run_still_current():
|
||
if not _agent_interrupted():
|
||
await _publish_native_progress()
|
||
return
|
||
finally:
|
||
if hasattr(adapter, "stop_native_task_card_progress"):
|
||
# Best-effort on the turn-cleanup path: an escaping transport exception would skip
|
||
# final-delivery logic (cleanup awaits catch only CancelledError).
|
||
try:
|
||
await adapter.stop_native_task_card_progress(
|
||
ctx.source.chat_id,
|
||
reply_to=ctx._progress_reply_to,
|
||
metadata=ctx._progress_metadata,
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception:
|
||
logger.debug(
|
||
"task-card stop failed during turn cleanup",
|
||
exc_info=True,
|
||
)
|
||
|
||
@dataclasses.dataclass
|
||
class _ProgressEditState:
|
||
"""Mutable editable-bubble state shared by ``send_progress_messages`` and its helpers."""
|
||
adapter: Any
|
||
progress_lines: list
|
||
progress_msg_id: Any
|
||
can_edit: bool
|
||
_progress_len_fn: Any
|
||
_PROGRESS_TEXT_LIMIT: int
|
||
_edit_accepts_metadata: bool
|
||
|
||
def _progress_edit_state(self, adapter) -> "TurnRunner._ProgressEditState":
|
||
ctx = self._ctx
|
||
progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble
|
||
progress_msg_id = None # ID of the current progress message to edit
|
||
can_edit = ctx.progress_grouping != "separate" # "separate" = one message per tool (pre-v0.9 behavior)
|
||
|
||
_progress_len_fn = (
|
||
adapter.message_len_fn
|
||
if isinstance(adapter, BasePlatformAdapter)
|
||
else len
|
||
)
|
||
try:
|
||
_raw_progress_limit = int(getattr(adapter, "MAX_MESSAGE_LENGTH", 4000) or 4000)
|
||
except Exception:
|
||
_raw_progress_limit = 4000
|
||
# Per-chat resolution (relay adapter fronting N platforms): cap and length unit follow the
|
||
# chat's underlying platform; native adapters return their scalar/property unchanged.
|
||
if isinstance(adapter, BasePlatformAdapter):
|
||
try:
|
||
_raw_progress_limit = int(
|
||
adapter.max_message_length_for_chat(ctx.source.chat_id) or 4000
|
||
)
|
||
_progress_len_fn = adapter.message_len_fn_for_chat(ctx.source.chat_id)
|
||
except Exception:
|
||
pass
|
||
# Leave a little room for platform quirks / formatting. For tiny
|
||
# test adapters keep the limit usable instead of clamping to 500+.
|
||
_PROGRESS_TEXT_LIMIT = max(
|
||
1,
|
||
_raw_progress_limit - (64 if _raw_progress_limit > 128 else 0),
|
||
)
|
||
|
||
# Detect whether the adapter's edit_message accepts metadata so
|
||
# overflow edits preserve Telegram topic/thread routing (#27487).
|
||
_edit_accepts_metadata = False
|
||
if ctx._progress_metadata:
|
||
try:
|
||
_edit_params = inspect.signature(adapter.edit_message).parameters
|
||
_edit_accepts_metadata = (
|
||
"metadata" in _edit_params
|
||
or any(
|
||
param.kind is inspect.Parameter.VAR_KEYWORD
|
||
for param in _edit_params.values()
|
||
)
|
||
)
|
||
except (TypeError, ValueError):
|
||
_edit_accepts_metadata = False
|
||
return self._ProgressEditState(
|
||
adapter=adapter,
|
||
progress_lines=progress_lines,
|
||
progress_msg_id=progress_msg_id,
|
||
can_edit=can_edit,
|
||
_progress_len_fn=_progress_len_fn,
|
||
_PROGRESS_TEXT_LIMIT=_PROGRESS_TEXT_LIMIT,
|
||
_edit_accepts_metadata=_edit_accepts_metadata,
|
||
)
|
||
|
||
async def _edit_progress_message(self, st, message_id: str, content: str):
|
||
ctx = self._ctx
|
||
kwargs = {
|
||
"chat_id": ctx.source.chat_id,
|
||
"message_id": message_id,
|
||
"content": content,
|
||
}
|
||
if getattr(st.adapter, "REQUIRES_EDIT_FINALIZE", False):
|
||
kwargs["finalize"] = True
|
||
if st._edit_accepts_metadata:
|
||
kwargs["metadata"] = ctx._progress_metadata
|
||
return await st.adapter.edit_message(**kwargs)
|
||
|
||
def _progress_text(self, lines: list) -> str:
|
||
return "\n".join(str(line) for line in lines)
|
||
|
||
def _split_progress_groups(self, st, lines: list) -> list[list]:
|
||
"""Partition progress lines into platform-sized editable bubbles."""
|
||
groups: list[list] = []
|
||
current: list = []
|
||
for line in lines:
|
||
candidate = current + [line]
|
||
if current and st._progress_len_fn(self._progress_text(candidate)) > st._PROGRESS_TEXT_LIMIT:
|
||
groups.append(current)
|
||
current = [line]
|
||
else:
|
||
current = candidate
|
||
if current:
|
||
groups.append(current)
|
||
return groups
|
||
|
||
def _track_progress_result(self, result) -> None:
|
||
ctx = self._ctx
|
||
if (
|
||
ctx._cleanup_progress
|
||
and getattr(result, "success", False)
|
||
and getattr(result, "message_id", None)
|
||
):
|
||
ctx._cleanup_msg_ids.append(str(result.message_id))
|
||
|
||
async def _send_progress_text(self, st, text: str):
|
||
ctx = self._ctx
|
||
result = await st.adapter.send(
|
||
chat_id=ctx.source.chat_id,
|
||
content=text,
|
||
reply_to=ctx._progress_reply_to,
|
||
metadata=ctx._progress_metadata,
|
||
)
|
||
self._track_progress_result(result)
|
||
return result
|
||
|
||
async def _roll_progress_overflow_if_needed(self, st) -> bool:
|
||
"""Start fresh editable progress bubbles before a bubble exceeds limit.
|
||
|
||
Returns True when it delivered/split the buffer or a transient edit failure left it
|
||
intact for retry — either way the caller skips the normal send/edit path this tick.
|
||
"""
|
||
if not st.progress_lines or not st.can_edit:
|
||
return False
|
||
groups = self._split_progress_groups(st, st.progress_lines)
|
||
if len(groups) <= 1:
|
||
return False
|
||
|
||
first_text = self._progress_text(groups[0])
|
||
if st.progress_msg_id is not None:
|
||
result = await self._edit_progress_message(st, st.progress_msg_id, first_text)
|
||
if not result.success:
|
||
if getattr(result, "retryable", False):
|
||
logger.debug(
|
||
"[%s] Transient overflow edit failure — keeping can_edit=True",
|
||
st.adapter.name,
|
||
)
|
||
return True
|
||
st.can_edit = False
|
||
# Fall back to the existing non-edit behavior below.
|
||
return False
|
||
else:
|
||
result = await self._send_progress_text(st, first_text)
|
||
if result.success and result.message_id:
|
||
st.progress_msg_id = result.message_id
|
||
|
||
for group in groups[1:]:
|
||
result = await self._send_progress_text(st, self._progress_text(group))
|
||
if result.success and result.message_id:
|
||
st.progress_msg_id = result.message_id
|
||
|
||
# The newest continuation is the only mutable bubble: keep just its lines so later
|
||
# edits update it instead of replaying the full transcript into new messages.
|
||
st.progress_lines = groups[-1]
|
||
return True
|
||
|
||
async def _drain_progress_on_cancel(self, st) -> None:
|
||
ctx = self._ctx
|
||
# Drain remaining queued messages
|
||
while not ctx.progress_queue.empty():
|
||
try:
|
||
raw = ctx.progress_queue.get_nowait()
|
||
if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__":
|
||
_, base_msg, count = raw
|
||
if st.progress_lines:
|
||
st.progress_lines[-1] = f"{base_msg} (×{count + 1})"
|
||
await self._roll_progress_overflow_if_needed(st)
|
||
elif isinstance(raw, tuple) and len(raw) >= 1 and raw[0] == "__reset__":
|
||
# Content-bubble marker during drain: close the current progress bubble
|
||
# and start a fresh one for tool lines that arrived after.
|
||
await self._roll_progress_overflow_if_needed(st)
|
||
if st.can_edit and st.progress_lines and st.progress_msg_id:
|
||
_pending_text = self._progress_text(st.progress_lines)
|
||
with suppress(Exception):
|
||
await self._edit_progress_message(st, st.progress_msg_id, _pending_text)
|
||
st.progress_msg_id = None
|
||
st.progress_lines = []
|
||
ctx.last_progress_msg[0] = None
|
||
ctx.repeat_count[0] = 0
|
||
else:
|
||
st.progress_lines.append(raw)
|
||
await self._roll_progress_overflow_if_needed(st)
|
||
except Exception:
|
||
break
|
||
# Final edit with all remaining tools (only if editing works)
|
||
if st.can_edit and st.progress_lines and st.progress_msg_id:
|
||
await self._roll_progress_overflow_if_needed(st)
|
||
if st.can_edit and st.progress_lines and st.progress_msg_id:
|
||
full_text = self._progress_text(st.progress_lines)
|
||
with suppress(Exception):
|
||
await self._edit_progress_message(st, st.progress_msg_id, full_text)
|
||
|
||
async def send_progress_messages(self):
|
||
ctx = self._ctx
|
||
if not ctx.progress_queue:
|
||
return
|
||
|
||
adapter = self._runner._adapter_for_source(ctx.source)
|
||
if not adapter:
|
||
return
|
||
|
||
if ctx._native_slack_task_cards and hasattr(
|
||
adapter, "send_native_task_card_progress"
|
||
):
|
||
await self._send_native_task_card_progress(adapter)
|
||
return
|
||
|
||
# Skip tool progress for platforms that can't edit messages (e.g. iMessage/BlueBubbles):
|
||
# each update would be a separate bubble. getattr, not attribute access: duck-typed
|
||
# adapters (test fakes, minimal plugins) may lack edit_message — treated as "can't edit".
|
||
_adapter_edit = getattr(type(adapter), "edit_message", None)
|
||
if _adapter_edit is None or _adapter_edit is BasePlatformAdapter.edit_message:
|
||
while not ctx.progress_queue.empty():
|
||
try:
|
||
ctx.progress_queue.get_nowait()
|
||
except Exception:
|
||
break
|
||
return
|
||
|
||
st = self._progress_edit_state(adapter)
|
||
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
|
||
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
|
||
|
||
while True:
|
||
try:
|
||
if not ctx._run_still_current():
|
||
while not ctx.progress_queue.empty():
|
||
try:
|
||
ctx.progress_queue.get_nowait()
|
||
except Exception:
|
||
break
|
||
return
|
||
|
||
raw = ctx.progress_queue.get_nowait()
|
||
|
||
# Drain silently when interrupted: events queued in the window between tool parse
|
||
# and interrupt processing should not render as bubbles.
|
||
try:
|
||
_agent_for_interrupt = ctx.agent_holder[0] if ctx.agent_holder else None
|
||
if _agent_for_interrupt is not None and getattr(
|
||
_agent_for_interrupt, "is_interrupted", False
|
||
):
|
||
# Drop this event and continue draining.
|
||
await asyncio.sleep(0)
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
# Handle dedup messages: update last line with repeat counter
|
||
if isinstance(raw, tuple) and len(raw) == 3 and raw[0] == "__dedup__":
|
||
_, base_msg, count = raw
|
||
if st.progress_lines:
|
||
st.progress_lines[-1] = f"{base_msg} (×{count + 1})"
|
||
msg = st.progress_lines[-1] if st.progress_lines else base_msg
|
||
elif isinstance(raw, tuple) and len(raw) >= 1 and raw[0] == "__reset__":
|
||
# Content bubble landed — close the tool-progress bubble so the next tool starts
|
||
# fresh below it; else tool edits hit the ORIGINAL message above (out of order).
|
||
st.progress_msg_id = None
|
||
st.progress_lines = []
|
||
ctx.last_progress_msg[0] = None
|
||
ctx.repeat_count[0] = 0
|
||
continue
|
||
else:
|
||
msg = raw
|
||
st.progress_lines.append(msg)
|
||
|
||
if await self._roll_progress_overflow_if_needed(st):
|
||
_last_edit_ts = time.monotonic()
|
||
await asyncio.sleep(0.3)
|
||
if ctx._run_still_current():
|
||
await st.adapter.send_typing(ctx.source.chat_id, metadata=ctx._progress_metadata)
|
||
continue
|
||
|
||
# Throttle edits: batch rapid tool updates into fewer API calls to avoid Telegram
|
||
# flood control (grammY pattern: proactively rate-limit rather than react to 429s).
|
||
_now = time.monotonic()
|
||
_remaining = _PROGRESS_EDIT_INTERVAL - (_now - _last_edit_ts)
|
||
if _remaining > 0:
|
||
# Wait out the throttle interval, then loop back to drain any further queued
|
||
# messages before sending a single batched edit.
|
||
await asyncio.sleep(_remaining)
|
||
continue
|
||
|
||
if not ctx._run_still_current():
|
||
return
|
||
|
||
if st.can_edit and st.progress_msg_id is not None:
|
||
# Try to edit the existing progress message
|
||
full_text = "\n".join(st.progress_lines)
|
||
result = await self._edit_progress_message(st, st.progress_msg_id, full_text)
|
||
if not result.success:
|
||
_err = (getattr(result, "error", "") or "").lower()
|
||
# Transient network errors (ConnectError, timeouts) must not disable editing;
|
||
# only permanent failures (flood, not found, permissions) set can_edit = False.
|
||
if getattr(result, "retryable", False):
|
||
logger.debug(
|
||
"[%s] Transient edit failure — keeping can_edit=True",
|
||
st.adapter.name,
|
||
)
|
||
continue
|
||
if "flood" in _err or "retry after" in _err:
|
||
# Flood control hit — backoff but keep editing.
|
||
# Only disable edits for non-recoverable errors.
|
||
logger.info(
|
||
"[%s] Progress edit flood control, backing off",
|
||
st.adapter.name,
|
||
)
|
||
_last_edit_ts = time.monotonic()
|
||
else:
|
||
st.can_edit = False
|
||
_flood_result = await st.adapter.send(
|
||
chat_id=ctx.source.chat_id,
|
||
content=msg,
|
||
reply_to=ctx._progress_reply_to,
|
||
metadata=ctx._progress_metadata,
|
||
)
|
||
if (
|
||
ctx._cleanup_progress
|
||
and getattr(_flood_result, "success", False)
|
||
and getattr(_flood_result, "message_id", None)
|
||
):
|
||
ctx._cleanup_msg_ids.append(str(_flood_result.message_id))
|
||
else:
|
||
if st.can_edit:
|
||
# First tool: send all accumulated text as new message
|
||
full_text = "\n".join(st.progress_lines)
|
||
result = await st.adapter.send(
|
||
chat_id=ctx.source.chat_id,
|
||
content=full_text,
|
||
reply_to=ctx._progress_reply_to,
|
||
metadata=ctx._progress_metadata,
|
||
)
|
||
else:
|
||
# Editing unsupported: send just this line
|
||
result = await st.adapter.send(
|
||
chat_id=ctx.source.chat_id,
|
||
content=msg,
|
||
reply_to=ctx._progress_reply_to,
|
||
metadata=ctx._progress_metadata,
|
||
)
|
||
if result.success and result.message_id:
|
||
st.progress_msg_id = result.message_id
|
||
if ctx._cleanup_progress:
|
||
ctx._cleanup_msg_ids.append(str(result.message_id))
|
||
|
||
_last_edit_ts = time.monotonic()
|
||
|
||
# Restore typing indicator
|
||
await asyncio.sleep(0.3)
|
||
if ctx._run_still_current():
|
||
await st.adapter.send_typing(ctx.source.chat_id, metadata=ctx._progress_metadata)
|
||
|
||
except queue.Empty:
|
||
await asyncio.sleep(0.3)
|
||
except asyncio.CancelledError:
|
||
await self._drain_progress_on_cancel(st)
|
||
return
|
||
except Exception as e:
|
||
logger.error("Progress message error: %s", e)
|
||
await asyncio.sleep(1)
|
||
|
||
def voice_ack_callback(self, call_id, tool_name, args):
|
||
"""tool_start_callback: speak a one-time ack in the voice channel."""
|
||
from gateway.run import safe_schedule_threadsafe
|
||
ctx = self._ctx
|
||
if ctx._voice_ack_fired[0] or ctx._voice_ack_guild[0] is None:
|
||
return
|
||
if not ctx._run_still_current():
|
||
return
|
||
ctx._voice_ack_fired[0] = True
|
||
_adapter = self._runner.adapters.get(Platform.DISCORD)
|
||
if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"):
|
||
return
|
||
try:
|
||
safe_schedule_threadsafe(
|
||
_adapter.play_ack_in_voice(ctx._voice_ack_guild[0]),
|
||
ctx._voice_ack_loop,
|
||
logger=logger,
|
||
log_message="voice ack scheduling error",
|
||
)
|
||
except Exception as _ack_err:
|
||
logger.debug("voice ack schedule failed: %s", _ack_err)
|
||
|
||
# ── Slack-native task cards: ID-bearing lifecycle callbacks ── ride agent.tool_start_callback /
|
||
# agent.tool_complete_callback so start/completion correlate by the REAL tool-call id; the
|
||
# name-correlated progress_callback text events would duplicate cards and mispair concurrent calls.
|
||
|
||
def native_tool_start_callback(self, call_id, tool_name, args):
|
||
"""Queue an ID-correlated native progress start from the agent thread."""
|
||
ctx = self._ctx
|
||
if not ctx.progress_queue or not ctx._run_still_current():
|
||
return
|
||
try:
|
||
_agent = ctx.agent_holder[0] if ctx.agent_holder else None
|
||
if _agent is not None and getattr(_agent, "is_interrupted", False):
|
||
return
|
||
except Exception:
|
||
pass
|
||
from agent.display import build_tool_preview
|
||
|
||
ctx.progress_queue.put(
|
||
{
|
||
"type": "tool.started",
|
||
"tool_call_id": str(call_id or ""),
|
||
"tool_name": str(tool_name or "tool"),
|
||
"preview": build_tool_preview(
|
||
str(tool_name or "tool"), args or {}, max_len=64
|
||
)
|
||
or "",
|
||
}
|
||
)
|
||
|
||
def native_tool_complete_callback(self, call_id, tool_name, args, result):
|
||
"""Queue the matching native completion using the real tool-call ID."""
|
||
ctx = self._ctx
|
||
if not ctx.progress_queue or not ctx._run_still_current():
|
||
return
|
||
try:
|
||
_agent = ctx.agent_holder[0] if ctx.agent_holder else None
|
||
if _agent is not None and getattr(_agent, "is_interrupted", False):
|
||
return
|
||
except Exception:
|
||
pass
|
||
from agent.display import _detect_tool_failure
|
||
|
||
is_error, _ = _detect_tool_failure(str(tool_name or "tool"), result)
|
||
ctx.progress_queue.put(
|
||
{
|
||
"type": "tool.completed",
|
||
"tool_call_id": str(call_id or ""),
|
||
"tool_name": str(tool_name or "tool"),
|
||
"is_error": bool(is_error),
|
||
}
|
||
)
|
||
|
||
def combined_tool_start_callback(self, call_id, tool_name, args):
|
||
"""Compose the voice ack + native task-card start consumers."""
|
||
ctx = self._ctx
|
||
if ctx._voice_ack_guild[0] is not None:
|
||
self.voice_ack_callback(call_id, tool_name, args)
|
||
if ctx._native_slack_task_cards:
|
||
self.native_tool_start_callback(call_id, tool_name, args)
|
||
|
||
def _step_callback_sync(self, iteration: int, prev_tools: list) -> None:
|
||
from gateway.run import safe_schedule_threadsafe
|
||
ctx = self._ctx
|
||
if not ctx._run_still_current():
|
||
return
|
||
# prev_tools may be list[str] or list[dict] with "name"/"result" keys. Normalise so
|
||
# "tool_names" stays backward-compatible for user hooks that do ', '.join(tool_names).
|
||
_names: list[str] = []
|
||
for _t in (prev_tools or []):
|
||
if isinstance(_t, dict):
|
||
_names.append(_t.get("name") or "")
|
||
else:
|
||
_names.append(str(_t))
|
||
safe_schedule_threadsafe(
|
||
ctx._hooks_ref.emit("agent:step", {
|
||
"platform": ctx.source.platform.value if ctx.source.platform else "",
|
||
"user_id": ctx.source.user_id,
|
||
"session_id": ctx.session_id,
|
||
"iteration": iteration,
|
||
"tool_names": _names,
|
||
"tools": prev_tools,
|
||
}),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="agent:step hook scheduling error",
|
||
)
|
||
|
||
def _event_callback_sync(self, event_type: str, context: dict) -> None:
|
||
ctx = self._ctx
|
||
try:
|
||
asyncio.run_coroutine_threadsafe(
|
||
ctx._hooks_ref.emit(event_type, context),
|
||
ctx._loop_for_step,
|
||
)
|
||
except Exception as _e:
|
||
logger.debug("event_callback hook error: %s", _e)
|
||
|
||
def _attach_session_title_callback(self, agent, ctx) -> None:
|
||
"""Wire the platform thread-rename lane onto the agent as `_on_session_title`.
|
||
|
||
The titler runs in the turn prologue, so attach before the run, not after it.
|
||
"""
|
||
try:
|
||
# Gateway auto-title failures are not user-actionable, so never surface them as messages;
|
||
# overriding the failure sink keeps CLI on _emit_auxiliary_failure while gateway logs debug.
|
||
def _title_failure_cb(task: str, exc: BaseException) -> None:
|
||
logger.debug(
|
||
"Gateway auto-title failure suppressed (not user-visible): %s: %s",
|
||
task, exc,
|
||
)
|
||
|
||
agent._title_failure_callback = _title_failure_cb
|
||
|
||
session_id = getattr(agent, "session_id", None)
|
||
source = ctx.source
|
||
|
||
# Both lanes spend a rate-limited platform call per title, so they use the model's title
|
||
# only (TitleCallback); renaming twice burns Discord's 2-per-10-min budget on a throwaway.
|
||
if self._runner._is_telegram_topic_lane(source):
|
||
agent._on_session_title = lambda title, title_source: (
|
||
title_source == "llm"
|
||
and self._runner._schedule_telegram_topic_title_rename(
|
||
source, session_id, title,
|
||
)
|
||
)
|
||
elif self._runner._is_discord_auto_thread_lane(source) or (
|
||
self._runner._is_relay_discord_channel_lane(source)
|
||
):
|
||
# Relay note: the second predicate is shape-only (relay Discord channel event).
|
||
# Whether the connector auto-threaded our reply is only knowable AFTER delivery, so
|
||
# the callback must be registered eagerly and the rename lane does the cache lookup
|
||
# at fire time — gating registration on the cache read meant it never registered.
|
||
agent._on_session_title = lambda title, title_source: (
|
||
title_source == "llm"
|
||
and self._runner._schedule_discord_semantic_thread_rename(
|
||
source, session_id, title,
|
||
)
|
||
)
|
||
except Exception:
|
||
logger.debug("Failed to attach session title callback", exc_info=True)
|
||
|
||
def _status_callback_sync(self, event_type: str, message: str) -> None:
|
||
from gateway.run import (
|
||
_prepare_gateway_status_message,
|
||
_redact_gateway_user_facing_secrets,
|
||
_send_or_update_status_coro,
|
||
safe_schedule_threadsafe,
|
||
)
|
||
ctx = self._ctx
|
||
if not ctx._status_adapter or not ctx._run_still_current():
|
||
return
|
||
prepared_message = _prepare_gateway_status_message(
|
||
ctx.source.platform,
|
||
event_type,
|
||
message,
|
||
)
|
||
if prepared_message is None:
|
||
logger.debug(
|
||
"status_callback suppressed for %s/%s: %s",
|
||
ctx.source.platform.value if ctx.source.platform else "unknown",
|
||
event_type,
|
||
_redact_gateway_user_facing_secrets(str(message or ""))[:160],
|
||
)
|
||
return
|
||
_fut = safe_schedule_threadsafe(
|
||
_send_or_update_status_coro(ctx._status_adapter, ctx._status_chat_id, event_type, prepared_message, ctx._status_thread_metadata),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message=f"status_callback ({event_type}) scheduling error",
|
||
)
|
||
if _fut is None:
|
||
return
|
||
if ctx._cleanup_progress:
|
||
def _track_status_id(fut) -> None:
|
||
try:
|
||
res = fut.result()
|
||
except Exception:
|
||
return
|
||
mid = getattr(res, "message_id", None)
|
||
if getattr(res, "success", False) and mid:
|
||
ctx._cleanup_msg_ids.append(str(mid))
|
||
_fut.add_done_callback(_track_status_id)
|
||
|
||
def _setup_stream_consumer(self, platform_key):
|
||
from gateway.run import safe_schedule_threadsafe
|
||
ctx = self._ctx
|
||
# Set up stream consumer for token streaming or interim commentary.
|
||
_stream_consumer = None
|
||
_stream_delta_cb = None
|
||
# streaming TTS consumer is created on the outer event-loop thread before run_sync launches.
|
||
# run_sync only reads it via ``streaming_tts_consumer_holder[0]`` for delta callback wiring.
|
||
_stts_consumer_ref = ctx.streaming_tts_consumer_holder[0]
|
||
_scfg = getattr(getattr(self._runner, 'config', None), 'streaming', None)
|
||
if _scfg is None:
|
||
from gateway.config import StreamingConfig
|
||
_scfg = StreamingConfig()
|
||
|
||
# Per-platform streaming gate: display.platforms.<plat>.streaming can disable streaming
|
||
# for specific platforms even when the global streaming config is enabled.
|
||
_plat_streaming = ctx.resolve_display_setting(
|
||
ctx.user_config, platform_key, "streaming"
|
||
)
|
||
# None = no per-platform override → follow global config
|
||
_streaming_enabled = (
|
||
_scfg.enabled and _scfg.transport != "off"
|
||
if _plat_streaming is None
|
||
else bool(_plat_streaming)
|
||
)
|
||
_want_stream_deltas = _streaming_enabled
|
||
_want_interim_messages = ctx.interim_assistant_messages_enabled
|
||
_want_interim_consumer = _want_interim_messages
|
||
if _want_stream_deltas or _want_interim_consumer:
|
||
try:
|
||
from gateway.stream_consumer import GatewayStreamConsumer
|
||
_adapter = self._runner._adapter_for_source(ctx.source)
|
||
if _adapter:
|
||
_consumer_cfg, _pause_typing_before_finalize = (
|
||
self._runner._build_stream_consumer_config(
|
||
ctx.source, _scfg, _adapter,
|
||
on_missing_cursor="raise",
|
||
)
|
||
)
|
||
_stream_consumer = GatewayStreamConsumer(
|
||
adapter=_adapter,
|
||
chat_id=ctx.source.chat_id,
|
||
config=_consumer_cfg,
|
||
metadata=ctx._status_thread_metadata,
|
||
on_new_message=(
|
||
(lambda: ctx.progress_queue.put(("__reset__",)))
|
||
if ctx.progress_queue is not None
|
||
else None
|
||
),
|
||
on_before_finalize=_pause_typing_before_finalize,
|
||
initial_reply_to_id=ctx.event_message_id,
|
||
run_still_current=ctx._run_still_current,
|
||
)
|
||
if _want_stream_deltas:
|
||
def _stream_delta_cb(text: str) -> None:
|
||
if ctx._run_still_current():
|
||
_stream_consumer.on_delta(text)
|
||
# Tee to the streaming-TTS consumer (#60671).
|
||
if _stts_consumer_ref is not None:
|
||
_stts_consumer_ref.on_delta(text)
|
||
ctx.stream_consumer_holder[0] = _stream_consumer
|
||
except Exception as _sc_err:
|
||
logger.debug("Could not set up stream consumer: %s", _sc_err)
|
||
|
||
# Text streaming off but streaming TTS active: install a TTS-only delta callback so the
|
||
# consumer still receives LLM deltas for audio synthesis.
|
||
if _stream_delta_cb is None and _stts_consumer_ref is not None:
|
||
def _stream_delta_cb(text: str) -> None:
|
||
if ctx._run_still_current():
|
||
_stts_consumer_ref.on_delta(text)
|
||
|
||
def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
|
||
if not ctx._run_still_current():
|
||
return
|
||
display_text = text
|
||
if _stream_consumer is not None:
|
||
if already_streamed:
|
||
_stream_consumer.on_segment_break()
|
||
else:
|
||
_stream_consumer.on_commentary(display_text)
|
||
return
|
||
if already_streamed or not ctx._status_adapter or not str(display_text or "").strip():
|
||
return
|
||
safe_schedule_threadsafe(
|
||
ctx._status_adapter.send(
|
||
ctx._status_chat_id,
|
||
display_text,
|
||
metadata=ctx._status_thread_metadata,
|
||
),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="interim_assistant_callback scheduling error",
|
||
)
|
||
return _stream_consumer, _stream_delta_cb, _interim_assistant_cb, _want_interim_messages
|
||
|
||
def _resolve_turn_agent(
|
||
self, turn_route, platform_key, combined_ephemeral, max_iterations, reasoning_config, pr,
|
||
):
|
||
from gateway.run import _AGENT_PENDING_SENTINEL, _checkpoint_agent_kwargs
|
||
ctx = self._ctx
|
||
# Per-platform skip_context_files — messaging platforms can opt out of filesystem-heavy
|
||
# context-file discovery (SOUL.md, AGENTS.md, .cursorrules) to cut AIAgent build latency.
|
||
_platforms_gw_cfg = (ctx.user_config.get("gateway") or {}).get("platforms") or {}
|
||
# ``hermes gateway setup`` writes ``gateway.platforms`` as a LIST of enabled platform names,
|
||
# not a dict; treat any non-dict shape as "no per-platform overrides" rather than crashing.
|
||
if not isinstance(_platforms_gw_cfg, dict):
|
||
_platforms_gw_cfg = {}
|
||
_plat_gw_cfg = _platforms_gw_cfg.get(platform_key) or {}
|
||
_skip_context = _plat_gw_cfg.get("skip_context_files")
|
||
skip_context_files = bool(_skip_context) if _skip_context is not None else False
|
||
|
||
# Agent cache: reuse this session's previous AIAgent to preserve the frozen system prompt
|
||
# and tool schemas for prompt cache hits.
|
||
_sig = self._runner._agent_config_signature(
|
||
turn_route["model"],
|
||
turn_route["runtime"],
|
||
ctx.enabled_toolsets,
|
||
combined_ephemeral,
|
||
cache_keys=self._runner._extract_cache_busting_config(ctx.user_config),
|
||
user_id=getattr(ctx.source, "user_id", None),
|
||
user_id_alt=getattr(ctx.source, "user_id_alt", None),
|
||
skip_context_files=skip_context_files,
|
||
)
|
||
agent = None
|
||
reused_cached_agent = False
|
||
_cache_lock = getattr(self._runner, "_agent_cache_lock", None)
|
||
_cache = getattr(self._runner, "_agent_cache", None)
|
||
|
||
# Peek at the cached entry's snapshot session_id so we can check, OUTSIDE the cache lock,
|
||
# whether it is a DEAD session in state.db. "cached sid != current sid" normally means an
|
||
# intentional switch (reuse the agent), but the routing-key self-heal yields the same shape
|
||
# with an agent bound to a DEAD session; reusing it re-binds the dead sid and loops.
|
||
_peek_cached_sid = None
|
||
if _cache_lock and _cache is not None:
|
||
with _cache_lock:
|
||
_peek_entry = _cache.get(ctx.session_key)
|
||
if _peek_entry and len(_peek_entry) > 3:
|
||
_peek_cached_sid = _peek_entry[3]
|
||
_cached_sid_is_dead = False
|
||
if (
|
||
_peek_cached_sid is not None
|
||
and ctx.session_id is not None
|
||
and _peek_cached_sid != ctx.session_id
|
||
):
|
||
try:
|
||
_cached_sid_is_dead = self._runner.session_store._is_session_ended_in_db(
|
||
_peek_cached_sid
|
||
)
|
||
except Exception:
|
||
_cached_sid_is_dead = False
|
||
|
||
# Cross-process write guard: another process (e.g. hermes dashboard) appending to the same
|
||
# SessionDB session makes the cached agent's transcript stale. On message_count mismatch vs
|
||
# the count recorded at cache time, invalidate so a fresh agent re-reads from disk.
|
||
_current_msg_count = None
|
||
if self._runner._session_db is not None and ctx.session_id:
|
||
try:
|
||
# run_sync is off-loop (executor); sync DB is fine.
|
||
_sess_row = self._runner._session_db._db.get_session(ctx.session_id)
|
||
if _sess_row:
|
||
_current_msg_count = _sess_row.get("message_count", 0)
|
||
except Exception:
|
||
pass
|
||
|
||
_xproc_evicted_agent = None
|
||
if _cache_lock and _cache is not None:
|
||
with _cache_lock:
|
||
cached = _cache.get(ctx.session_key)
|
||
if cached and cached[1] == _sig:
|
||
# cached[2] is the message_count at cache time; stale when a second process
|
||
# appended rows. cached[3] (when present) is the session_id the snapshot was
|
||
# taken for — used to skip the guard when the active session_id differs.
|
||
_cached_mc = cached[2] if len(cached) > 2 else None
|
||
_cached_sid = cached[3] if len(cached) > 3 else None
|
||
# Snapshot from a different session_id (same session_key, other conversation): the
|
||
# counts track DIFFERENT DB rows, so the comparison is meaningless. REUSE the cached
|
||
# agent rather than rebuild and bust the prompt cache on every session switch.
|
||
_session_id_mismatch = (
|
||
_cached_sid is not None
|
||
and ctx.session_id is not None
|
||
and _cached_sid != ctx.session_id
|
||
)
|
||
# Re-validate the OUTSIDE-lock dead-session peek against the tuple read under THIS
|
||
# lock: the entry may have been replaced between peek and acquisition, and a stale
|
||
# "dead" verdict must never be applied to a different (possibly live) cached agent.
|
||
_stale_dead_sid_reuse = (
|
||
_session_id_mismatch
|
||
and _cached_sid_is_dead
|
||
and _cached_sid == _peek_cached_sid
|
||
)
|
||
if _stale_dead_sid_reuse:
|
||
# The routing key was just self-healed away from a session state.db marked
|
||
# ended, but this cached AIAgent still belongs to that DEAD session_id.
|
||
# Reusing it would re-bind the dead sid and undo the self-heal; rebuild fresh.
|
||
logger.info(
|
||
"Agent cache invalidated for session %s: "
|
||
"cached agent's session_id %s is ended in "
|
||
"state.db (stale self-heal artifact, "
|
||
"#54878 x #54947) — discarding instead of "
|
||
"reusing across the routing recovery",
|
||
ctx.session_key, _cached_sid,
|
||
)
|
||
evicted = self._runner._agent_cache.pop(ctx.session_key, None)
|
||
_ev_agent = evicted[0] if isinstance(evicted, tuple) and evicted else None
|
||
if _ev_agent and _ev_agent is not _AGENT_PENDING_SENTINEL:
|
||
# Same deferred-cleanup rationale as the cross-process branch below: don't
|
||
# block the event loop / cache lock on memory-provider or socket teardown.
|
||
_xproc_evicted_agent = _ev_agent
|
||
elif (
|
||
not _session_id_mismatch
|
||
and _cached_mc is not None
|
||
and _current_msg_count is not None
|
||
and _current_msg_count != _cached_mc
|
||
):
|
||
# Cross-process write detected — discard stale
|
||
# agent so it rebuilds from fresh DB transcript.
|
||
logger.info(
|
||
"Agent cache invalidated for session %s: "
|
||
"message_count changed (%s -> %s), "
|
||
"possible cross-process write",
|
||
ctx.session_key, _cached_mc, _current_msg_count,
|
||
)
|
||
evicted = self._runner._agent_cache.pop(ctx.session_key, None)
|
||
_ev_agent = evicted[0] if isinstance(evicted, tuple) and evicted else None
|
||
if _ev_agent and _ev_agent is not _AGENT_PENDING_SENTINEL:
|
||
# Defer cleanup until AFTER the lock is released: release_clients can
|
||
# block on memory-provider/socket teardown, stalling the event loop while
|
||
# the idle sweeper waits on this lock (blocking Discord heartbeats). The
|
||
# session rebuilds a fresh agent below, so use the SOFT release that keeps
|
||
# its terminal sandbox / browser / bg processes for the new agent to
|
||
# inherit — mirrors _evict_cached_agent / idle-sweep.
|
||
_xproc_evicted_agent = _ev_agent
|
||
else:
|
||
agent = cached[0]
|
||
# Refresh LRU order so the cap enforcement evicts
|
||
# truly-oldest entries, not the one we just used.
|
||
if hasattr(_cache, "move_to_end"):
|
||
with suppress(KeyError):
|
||
_cache.move_to_end(ctx.session_key)
|
||
self._runner._init_cached_agent_for_turn(agent, ctx._interrupt_depth)
|
||
# Refresh agent max_iterations from current config
|
||
# (cached agent may have been created with old config)
|
||
agent.max_iterations = max_iterations
|
||
logger.debug("Reusing cached agent for session %s", ctx.session_key)
|
||
reused_cached_agent = True
|
||
|
||
# Lock released — refresh the reused agent's fallback chain from disk OUTSIDE the cache lock
|
||
# (disk I/O under the lock stalls the idle-sweep watcher and Discord heartbeats). A chain
|
||
# configured after caching must reach the next turn; per-session serialization keeps it safe.
|
||
if reused_cached_agent and agent is not None:
|
||
self._runner._apply_fallback_chain_to_agent(
|
||
agent, self._runner._refresh_fallback_model(),
|
||
)
|
||
|
||
# Lock released — schedule cleanup of any cross-process-evicted agent on a daemon thread so
|
||
# memory-provider/socket teardown never blocks the gateway loop or the expiry watcher's lock.
|
||
if _xproc_evicted_agent is not None:
|
||
try:
|
||
threading.Thread(
|
||
target=self._runner._release_evicted_agent_soft,
|
||
args=(_xproc_evicted_agent,),
|
||
daemon=True,
|
||
name=f"agent-xproc-evict-{str(ctx.session_key)[:24]}",
|
||
).start()
|
||
except Exception:
|
||
# Interpreter shutdown or thread-spawn failure — release
|
||
# inline as a best-effort fallback.
|
||
with suppress(Exception):
|
||
self._runner._release_evicted_agent_soft(_xproc_evicted_agent)
|
||
|
||
if agent is None:
|
||
# Config changed or first message — create fresh agent
|
||
agent = ctx.AIAgent(
|
||
model=turn_route["model"],
|
||
**turn_route["runtime"],
|
||
**_checkpoint_agent_kwargs(ctx.user_config),
|
||
max_iterations=max_iterations,
|
||
quiet_mode=True,
|
||
verbose_logging=False,
|
||
enabled_toolsets=ctx.enabled_toolsets,
|
||
disabled_toolsets=ctx.disabled_toolsets,
|
||
ephemeral_system_prompt=combined_ephemeral or None,
|
||
prefill_messages=self._runner._prefill_messages or None,
|
||
reasoning_config=reasoning_config,
|
||
service_tier=self._runner._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=ctx.session_id,
|
||
platform=platform_key,
|
||
user_id=ctx.source.user_id,
|
||
user_id_alt=ctx.source.user_id_alt,
|
||
user_name=ctx.source.user_name,
|
||
chat_id=ctx.source.chat_id,
|
||
chat_name=ctx.source.chat_name,
|
||
chat_type=ctx.source.chat_type,
|
||
thread_id=ctx.source.thread_id,
|
||
gateway_session_key=ctx.session_key,
|
||
session_db=getattr(self._runner._session_db, "_db", self._runner._session_db),
|
||
# Reload from disk — do not reuse the startup snapshot (#60955).
|
||
fallback_model=self._runner._refresh_fallback_model(),
|
||
skip_context_files=skip_context_files,
|
||
# Keep the persona even with minimal context: soul identity is
|
||
# a single small file, not part of the expensive walk.
|
||
load_soul_identity=True,
|
||
)
|
||
if _cache_lock and _cache is not None:
|
||
with _cache_lock:
|
||
# Record the snapshot's session_id with message_count so the cross-process guard can
|
||
# skip the meaningless count comparison if the active session_id later switches.
|
||
_cache[ctx.session_key] = (
|
||
agent, _sig, _current_msg_count, ctx.session_id,
|
||
)
|
||
self._runner._enforce_agent_cache_cap()
|
||
logger.debug("Created new agent for session %s (sig=%s)", ctx.session_key, _sig)
|
||
return agent, reused_cached_agent
|
||
|
||
def _wire_turn_agent_callbacks(
|
||
self, agent, turn_route, reasoning_config,
|
||
_stream_delta_cb, _interim_assistant_cb, _want_interim_messages,
|
||
):
|
||
from gateway.run import (
|
||
_interim_metadata,
|
||
_non_conversational_metadata,
|
||
render_notice_line,
|
||
safe_schedule_threadsafe,
|
||
)
|
||
ctx = self._ctx
|
||
# Per-message state — callbacks and reasoning config change every turn, so they aren't baked
|
||
# into the cached agent. The progress callback is ALWAYS attached (never gated to None): its
|
||
# body gates each event class, and subagent-failure notices must fire even with
|
||
# tool_progress/thinking off — a None gate made dead subagents vanish silently.
|
||
agent.tool_progress_callback = ctx.progress_callback
|
||
# Compose ID-bearing lifecycle consumers: Discord's one-time voice ack and Slack's task cards
|
||
# both ride the authoritative start callback, so neither infers identity from tool names.
|
||
_combined_start_cb = ctx.native_tool_start_callback or ctx.voice_ack_callback
|
||
agent.tool_start_callback = (
|
||
_combined_start_cb
|
||
if (
|
||
ctx._voice_ack_guild[0] is not None
|
||
or ctx._native_slack_task_cards
|
||
)
|
||
else None
|
||
)
|
||
agent.tool_complete_callback = (
|
||
ctx.native_tool_complete_callback
|
||
if ctx._native_slack_task_cards
|
||
and ctx.native_tool_complete_callback is not None
|
||
else None
|
||
)
|
||
agent.step_callback = ctx._step_callback_sync if ctx._hooks_ref.loaded_hooks else None
|
||
agent.stream_delta_callback = _stream_delta_cb
|
||
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
|
||
agent.status_callback = ctx._status_callback_sync
|
||
# Credits / out-of-band notices (usage bands, depletion, restored) fire from the agent's sync
|
||
# worker thread, so hop onto the gateway loop via safe_schedule_threadsafe. Fired-once latch
|
||
# lives on the cached agent (no per-turn re-nag); clear is a no-op — sends can't be retracted.
|
||
def _notice_callback_sync(notice) -> None:
|
||
if not ctx._status_adapter or not ctx._run_still_current():
|
||
return
|
||
try:
|
||
line = render_notice_line(notice)
|
||
except Exception:
|
||
logger.debug("render_notice_line failed", exc_info=True)
|
||
return
|
||
if not line:
|
||
return
|
||
safe_schedule_threadsafe(
|
||
self._runner._deliver_platform_notice(ctx.source, line),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="notice_callback delivery scheduling error",
|
||
)
|
||
|
||
agent.notice_callback = _notice_callback_sync
|
||
agent.notice_clear_callback = None
|
||
agent.event_callback = ctx._event_callback_sync
|
||
agent.reasoning_config = reasoning_config
|
||
agent.service_tier = self._runner._service_tier
|
||
# Merge, never overwrite: init-time request overrides (e.g. a custom provider's extra_body
|
||
# merged at agent construction) must survive every reused-agent turn. Drop only the PREVIOUS
|
||
# turn's routing overrides before layering this turn's, so stale per-turn values never linger.
|
||
request_overrides = dict(getattr(agent, "request_overrides", {}) or {})
|
||
previous_turn_overrides = dict(
|
||
getattr(agent, "_gateway_turn_request_overrides", {}) or {}
|
||
)
|
||
for key, value in previous_turn_overrides.items():
|
||
if request_overrides.get(key) == value:
|
||
request_overrides.pop(key, None)
|
||
turn_request_overrides = dict(turn_route.get("request_overrides") or {})
|
||
request_overrides.update(turn_request_overrides)
|
||
agent.request_overrides = request_overrides
|
||
agent._gateway_turn_request_overrides = turn_request_overrides
|
||
# Must-deliver notes for THIS turn ride the current user message (api_content sidecar), never
|
||
# the system prompt: staged by _handle_message_with_agent (auto-reset, first-contact intro,
|
||
# voice-channel change). Assigned unconditionally so a reused agent never replays a stale note.
|
||
agent._gateway_turn_context_notes = "\n\n".join(
|
||
self._runner._consume_pending_turn_sidecar_notes(ctx.session_key)
|
||
)
|
||
|
||
_bg_review_release = threading.Event()
|
||
_bg_review_pending: list[str] = []
|
||
_bg_review_pending_lock = threading.Lock()
|
||
|
||
def _deliver_bg_review_message(message: str) -> None:
|
||
if not ctx._status_adapter or not ctx._run_still_current():
|
||
return
|
||
safe_schedule_threadsafe(
|
||
ctx._status_adapter.send(
|
||
ctx._status_chat_id,
|
||
message,
|
||
metadata=_interim_metadata(_non_conversational_metadata(ctx._status_thread_metadata, platform=ctx.source.platform)),
|
||
),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="background_review_callback scheduling error",
|
||
)
|
||
|
||
def _release_bg_review_messages() -> None:
|
||
_bg_review_release.set()
|
||
with _bg_review_pending_lock:
|
||
pending = list(_bg_review_pending)
|
||
_bg_review_pending.clear()
|
||
for queued in pending:
|
||
_deliver_bg_review_message(queued)
|
||
|
||
# Background review delivery — send "💾 Memory updated" etc. to user
|
||
def _bg_review_send(message: str) -> None:
|
||
if not ctx._status_adapter or not ctx._run_still_current():
|
||
return
|
||
if not _bg_review_release.is_set():
|
||
with _bg_review_pending_lock:
|
||
if not _bg_review_release.is_set():
|
||
_bg_review_pending.append(message)
|
||
return
|
||
_deliver_bg_review_message(message)
|
||
|
||
agent.background_review_callback = _bg_review_send
|
||
# Register the release hook on the adapter so base.py's finally
|
||
# block can fire it after delivering the main response.
|
||
if ctx._status_adapter and ctx.session_key:
|
||
if getattr(type(ctx._status_adapter), "register_post_delivery_callback", None) is not None:
|
||
ctx._status_adapter.register_post_delivery_callback(
|
||
ctx.session_key,
|
||
_release_bg_review_messages,
|
||
generation=ctx.run_generation,
|
||
)
|
||
else:
|
||
_pdc = getattr(ctx._status_adapter, "_post_delivery_callbacks", None)
|
||
if _pdc is not None:
|
||
_pdc[ctx.session_key] = _release_bg_review_messages
|
||
# Memory update notifications in chat. Config: display.memory_notifications
|
||
# off — no chat notification (still logged to stdout)
|
||
# on — generic "💾 Memory updated" (default)
|
||
# verbose — content preview: "💾 Memory ➕ Hermes Repo..."
|
||
_mem_notif = ctx.user_config.get("display", {}).get("memory_notifications")
|
||
if isinstance(_mem_notif, bool):
|
||
_mem_notif = "on" if _mem_notif else "off"
|
||
agent.memory_notifications = str(_mem_notif).lower() if _mem_notif else "on"
|
||
|
||
agent.clarify_callback = self._clarify_callback_sync
|
||
|
||
# Show assistant thinking between tool calls — independent of tool_progress mode. Mattermost
|
||
# needs an explicit per-platform opt-in so global scratch-text doesn't leak into threads.
|
||
agent.thinking_progress = ctx._thinking_enabled
|
||
# Store agent reference for interrupt support
|
||
ctx.agent_holder[0] = agent
|
||
# Wire the platform thread-rename lane onto the agent: the titler fires from the turn prologue,
|
||
# not after the response, so titles are pushed the moment they land.
|
||
self._attach_session_title_callback(agent, ctx)
|
||
# Publish turn ownership for explicit /stop, /new, disconnect, and shutdown interrupts.
|
||
# Older session processes are outside this baseline and remain alive.
|
||
agent._gateway_turn_process_task_id = ctx.process_task_id
|
||
agent._gateway_turn_process_baseline = ctx.process_baseline
|
||
# Capture the full tool definitions for transcript logging
|
||
ctx.tools_holder[0] = agent.tools if hasattr(agent, 'tools') else None
|
||
|
||
# ------------------------------------------------------------------
|
||
# Shared native-stream boundary close: for native-streaming platforms (e.g. WeCom), an
|
||
# interrupting interaction (approval or clarify prompt) must finalize the current stream
|
||
# and disable native streaming first, or post-interaction output keeps updating the OLD
|
||
# bubble above the prompt. Runs on the agent thread; the consumer serializes via its queue.
|
||
def _close_native_stream_boundary(
|
||
self, _reason: str, _placeholder: str | None = None, _reopen: bool = False,
|
||
) -> bool:
|
||
ctx = self._ctx
|
||
_sc = ctx.stream_consumer_holder[0] if ctx.stream_consumer_holder else None
|
||
if not (_sc and getattr(_sc, "_use_native_streaming", False)):
|
||
return True
|
||
_cancelled_flag = None
|
||
try:
|
||
_boundary_result = _sc.close_for_approval_prompt(
|
||
_placeholder, reason=_reason, reopen=_reopen,
|
||
)
|
||
# Returns (future, cancelled_flag) or just a future.
|
||
if isinstance(_boundary_result, tuple):
|
||
_boundary_future, _cancelled_flag = _boundary_result
|
||
else:
|
||
_boundary_future = _boundary_result
|
||
if hasattr(_boundary_future, "result"):
|
||
_ok = _boundary_future.result(timeout=10)
|
||
if not _ok:
|
||
logger.warning(
|
||
"%s boundary failed to close stream properly — "
|
||
"prompt may still appear in typing bubble", _reason,
|
||
)
|
||
return bool(_ok)
|
||
return True
|
||
except (TimeoutError, Exception) as _boundary_err:
|
||
if _cancelled_flag is not None:
|
||
_cancelled_flag["cancelled"] = True
|
||
logger.warning(
|
||
"%s boundary timed out or failed: %s", _reason, _boundary_err,
|
||
)
|
||
return False
|
||
|
||
# ------------------------------------------------------------------
|
||
# Clarify callback: present a clarify prompt and block on a response. Runs on the agent's
|
||
# worker thread (clarify_tool's synchronous contract): schedules the adapter's send_clarify
|
||
# on the gateway loop, then blocks on the primitive's threading.Event with a timeout.
|
||
# Returns the response string, or a sentinel explaining no response arrived.
|
||
# ------------------------------------------------------------------
|
||
def _clarify_callback_sync(self, question: str, choices, multi_select: bool = False) -> str:
|
||
from gateway.run import _clarify_send_then_wait, safe_schedule_threadsafe
|
||
ctx = self._ctx
|
||
from tools import clarify_gateway as _clarify_mod
|
||
import uuid as _uuid
|
||
|
||
if not ctx._status_adapter:
|
||
return ""
|
||
|
||
clarify_id = _uuid.uuid4().hex[:10]
|
||
_clarify_mod.register(
|
||
clarify_id=clarify_id,
|
||
session_key=ctx.session_key or "",
|
||
question=question,
|
||
choices=list(choices) if choices else None,
|
||
multi_select=bool(multi_select),
|
||
)
|
||
|
||
# WeCom native streaming: finalize the current stream before the clarify prompt so the
|
||
# post-answer output opens a fresh bubble below the question ("气泡割裂" otherwise). Unlike
|
||
# approval, clarify passes reopen=True so the continuation re-opens a native stream; if
|
||
# the re-seed fails the consumer degrades to send() automatically.
|
||
self._close_native_stream_boundary(
|
||
"Clarify", "💬 等待你的选择...", _reopen=True,
|
||
)
|
||
|
||
# Pause typing — as with approval, a "thinking..." status must not obscure the prompt or
|
||
# block an "Other" reply on platforms that disable input while typing (Slack Assistant).
|
||
with suppress(Exception):
|
||
ctx._status_adapter.pause_typing_for_chat(ctx._status_chat_id)
|
||
|
||
# Ordering barrier: flush buffered assistant prose to the platform BEFORE sending the
|
||
# poll, which goes out on a separate agent-thread-blocking path and would otherwise
|
||
# render ABOVE its own explanation. Best-effort + short timeout so the agent thread
|
||
# never hangs if the consumer task isn't running.
|
||
try:
|
||
_sc = ctx.stream_consumer_holder[0] if ctx.stream_consumer_holder else None
|
||
_flush = getattr(_sc, "flush_pending_sync", None)
|
||
if callable(_flush):
|
||
_flush(timeout=3.0)
|
||
except Exception:
|
||
logger.debug(
|
||
"Stream-consumer flush before clarify prompt failed",
|
||
exc_info=True,
|
||
)
|
||
|
||
fut = safe_schedule_threadsafe(
|
||
ctx._status_adapter.send_clarify(
|
||
chat_id=ctx._status_chat_id,
|
||
question=question,
|
||
choices=list(choices) if choices else None,
|
||
clarify_id=clarify_id,
|
||
session_key=ctx.session_key or "",
|
||
metadata=ctx._status_thread_metadata,
|
||
),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="Clarify send failed to schedule",
|
||
)
|
||
# Boundary rule (see _approval_send_outcome): a send timeout is AMBIGUOUS — the card may
|
||
# have posted with a late ack. Only a definitive failure tears down the registration;
|
||
# ambiguous falls through to the bounded wait so a late reply resolves.
|
||
_clarify_response = _clarify_send_then_wait(
|
||
fut,
|
||
clarify_id=clarify_id,
|
||
session_key=ctx.session_key or "",
|
||
clarify_mod=_clarify_mod,
|
||
)
|
||
# Only re-arm typing when the user actually answered — the undeliverable sentinel and the
|
||
# timeout/cancellation strings start with '[' and must pass through untouched.
|
||
if not (
|
||
isinstance(_clarify_response, str)
|
||
and _clarify_response.startswith("[")
|
||
):
|
||
# User answered: reopen typing IMMEDIATELY, not on the LLM's first post-answer token
|
||
# (native streaming otherwise re-seeds lazily on the first delta: ~48s of dead air).
|
||
# request_reopen_seed is a no-op outside the reopen-pending native state; always safe.
|
||
_sc_reopen = ctx.stream_consumer_holder[0] if ctx.stream_consumer_holder else None
|
||
if _sc_reopen is not None:
|
||
try:
|
||
_sc_reopen.request_reopen_seed()
|
||
except Exception:
|
||
logger.debug(
|
||
"request_reopen_seed after clarify answer failed",
|
||
exc_info=True,
|
||
)
|
||
try:
|
||
ctx._status_adapter.resume_typing_for_chat(ctx._status_chat_id)
|
||
except Exception:
|
||
logger.debug(
|
||
"resume_typing_for_chat after clarify answer failed",
|
||
exc_info=True,
|
||
)
|
||
return _clarify_response
|
||
|
||
def _load_turn_history(self, agent, reused_cached_agent):
|
||
from gateway.run import (
|
||
_build_gateway_agent_history,
|
||
_collect_history_media_paths,
|
||
_message_timestamps_enabled,
|
||
_select_cached_agent_history,
|
||
)
|
||
ctx = self._ctx
|
||
# Convert history to agent format. Transcript path: {role, content, timestamp} dicts — strip
|
||
# timestamps. Interrupt path (agent result["messages"]): full agent messages with
|
||
# tool_calls/tool_call_id/reasoning — pass through intact so the API sees valid assistant→tool
|
||
# sequences (dropping tool_calls causes 500s). Telegram observed group context: observed=True
|
||
# rows are withheld from replayable history and attached to the current addressed message as
|
||
# API-only context, so persisted history stores only the real addressed user turn.
|
||
agent_history, observed_group_context = _build_gateway_agent_history(
|
||
ctx.history,
|
||
channel_prompt=ctx.channel_prompt,
|
||
inject_timestamps=_message_timestamps_enabled(ctx.user_config),
|
||
)
|
||
|
||
# FTS write-corruption guard: if persistence failed silently via corrupt FTS triggers, the
|
||
# reloaded transcript is stale/empty while the SAME cached agent still holds the full live
|
||
# conversation in `_session_messages`; replacing it causes same-session amnesia. Only for
|
||
# a reused agent bound to this exact session_id.
|
||
if reused_cached_agent and getattr(agent, "session_id", None) == ctx.session_id:
|
||
_selected = _select_cached_agent_history(
|
||
agent_history, getattr(agent, "_session_messages", None)
|
||
)
|
||
if _selected is not agent_history:
|
||
logger.warning(
|
||
"Persisted transcript lagged live cached history for "
|
||
"session %s (disk=%d, memory=%d); preserving live "
|
||
"conversation context (possible FTS write corruption)",
|
||
ctx.session_key, len(agent_history), len(_selected),
|
||
)
|
||
# The live in-memory history bypassed the _build_gateway_agent_history cleanup above —
|
||
# re-apply the stale-confirmation expiry so a dangerous confirmation can't slip through.
|
||
agent_history = strip_stale_dangerous_confirmations(
|
||
_selected, now=time.time()
|
||
)
|
||
|
||
# Collect MEDIA paths already in history to exclude them from this turn's extraction.
|
||
# Compression-safe: even if the message list shrinks, we know which paths are old.
|
||
_history_media_paths: set = _collect_history_media_paths(agent_history)
|
||
return agent_history, observed_group_context, _history_media_paths
|
||
|
||
def _approval_notify_sync(self, approval_data: dict) -> None:
|
||
"""Send the approval request to the user from the agent thread.
|
||
|
||
Uses the adapter's interactive button approvals (e.g. ``send_exec_approval``) when
|
||
available, else a plain text message with ``/approve`` instructions.
|
||
"""
|
||
from gateway.run import (
|
||
_approval_send_outcome,
|
||
_format_exec_approval_fallback,
|
||
_interim_metadata,
|
||
_redact_approval_command,
|
||
safe_schedule_threadsafe,
|
||
)
|
||
ctx = self._ctx
|
||
# Pause typing while awaiting approval: Slack's assistant_threads_setStatus disables the
|
||
# compose box, so the user can't type /approve while "is thinking..." shows. The approval
|
||
# send auto-clears it; pausing stops _keep_typing re-setting it. Resumed in approve/deny.
|
||
ctx._status_adapter.pause_typing_for_chat(ctx._status_chat_id)
|
||
|
||
# WeCom native streaming: ask the stream consumer to close the current stream before the
|
||
# approval prompt — via the consumer's queue, so it serializes with pending deltas.
|
||
self._close_native_stream_boundary("Approval")
|
||
|
||
cmd = approval_data.get("command", "")
|
||
desc = approval_data.get("description", "dangerous command")
|
||
|
||
# Redact credentials from the command before display — Tirith's findings are already
|
||
# redacted, but the raw command string still leaks secrets to the chat platform. Done
|
||
# here so BOTH the button-based and plain-text fallback paths use the redacted value.
|
||
cmd = _redact_approval_command(cmd)
|
||
|
||
# Prefer button-based approval when the adapter supports it. Check the *class*, not the
|
||
# instance — avoids false positives from MagicMock auto-attribute creation in tests.
|
||
if getattr(type(ctx._status_adapter), "send_exec_approval", None) is not None:
|
||
try:
|
||
_approval_fut = safe_schedule_threadsafe(
|
||
ctx._status_adapter.send_exec_approval(
|
||
chat_id=ctx._status_chat_id,
|
||
command=cmd,
|
||
session_key=ctx.session_key or "",
|
||
description=desc,
|
||
metadata=ctx._status_thread_metadata,
|
||
allow_permanent=approval_data.get("allow_permanent", True),
|
||
allow_session=approval_data.get("allow_session", True),
|
||
smart_denied=approval_data.get("smart_denied", False),
|
||
),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="send_exec_approval scheduling error",
|
||
)
|
||
if _approval_fut is None:
|
||
raise RuntimeError("send_exec_approval: loop unavailable")
|
||
_outcome = _approval_send_outcome(_approval_fut, timeout=15)
|
||
if _outcome == "sent":
|
||
return
|
||
if _outcome == "ambiguous":
|
||
# Timeout ≠ failure: the card may have posted with a late ack (slow API or
|
||
# backpressure). The prompt registration stays alive so a tap still resolves;
|
||
# re-sending made duplicate cards + orphaned "/approve: nothing pending". Skip.
|
||
logger.warning(
|
||
"Button-based approval send timed out — treating "
|
||
"as possibly-delivered (no re-send; the prompt "
|
||
"stays armed for a late tap)"
|
||
)
|
||
return
|
||
logger.warning(
|
||
"Button-based approval failed (send returned error), falling back to text"
|
||
)
|
||
except Exception as _e:
|
||
logger.warning(
|
||
"Button-based approval failed, falling back to text: %s", _e
|
||
)
|
||
|
||
# Fallback: plain-text approval prompt with the adapter's typed prefix (e.g. `!approve`) —
|
||
# typed "/" is blocked in Slack threads and reserved by Matrix clients.
|
||
_p = getattr(ctx._status_adapter, "typed_command_prefix", "/")
|
||
msg = _format_exec_approval_fallback(
|
||
cmd,
|
||
desc,
|
||
_p,
|
||
allow_permanent=approval_data.get("allow_permanent", True),
|
||
allow_session=approval_data.get("allow_session", True),
|
||
smart_denied=approval_data.get("smart_denied", False),
|
||
)
|
||
try:
|
||
# Mark as approval prompt so WeCom routes through control lane
|
||
_approval_metadata = dict(ctx._status_thread_metadata or {})
|
||
_approval_metadata["is_approval_prompt"] = True
|
||
|
||
_approval_send_fut = safe_schedule_threadsafe(
|
||
ctx._status_adapter.send(
|
||
ctx._status_chat_id,
|
||
msg,
|
||
metadata=_interim_metadata(_approval_metadata),
|
||
),
|
||
ctx._loop_for_step,
|
||
logger=logger,
|
||
log_message="Approval text-send scheduling error",
|
||
)
|
||
if _approval_send_fut is not None:
|
||
_approval_send_fut.result(timeout=15)
|
||
except Exception as _e:
|
||
logger.error("Failed to send approval request: %s", _e)
|
||
|
||
def _prepare_turn_message(self, agent_history):
|
||
from gateway.run import (
|
||
_auto_continue_freshness_window,
|
||
_is_fresh_gateway_interruption,
|
||
_last_transcript_timestamp,
|
||
_prepare_resume_pending_message,
|
||
build_resume_recovery_note,
|
||
)
|
||
ctx = self._ctx
|
||
# Keep real user text separate from API-only recovery guidance: if an auto-continue note is
|
||
# prepended below, persist the original so stale guidance never replays as user text.
|
||
_persist_user_message_override: Optional[Any] = ctx.persist_user_message
|
||
_persist_user_timestamp_override: Optional[float] = ctx.persist_user_timestamp
|
||
|
||
# Prepend pending model switch note so the model knows about the switch
|
||
_pending_notes = getattr(self._runner, '_pending_model_notes', {})
|
||
_msn = _pending_notes.pop(ctx.session_key, None) if ctx.session_key else None
|
||
if _msn:
|
||
ctx.message = _msn + "\n\n" + ctx.message
|
||
|
||
# Auto-continue: history ending with a tool result means the previous turn was cut off
|
||
# (restart, crash, SIGTERM) — prepend a system note so the model finishes the pending tool
|
||
# results first. Session-level resume_pending (drain-timeout shutdown) uses stronger
|
||
# reason-aware wording that subsumes this case. Both gate on the age of ``history[-1]`` (not
|
||
# agent_history, which stripped ``timestamp`` off tool rows); rows without one are fresh.
|
||
_freshness_window = _auto_continue_freshness_window()
|
||
_interruption_is_fresh = _is_fresh_gateway_interruption(
|
||
_last_transcript_timestamp(ctx.history),
|
||
window_secs=_freshness_window,
|
||
)
|
||
|
||
_resume_entry = None
|
||
if ctx.session_key:
|
||
try:
|
||
_resume_entry = self._runner.session_store._entries.get(ctx.session_key)
|
||
except Exception:
|
||
_resume_entry = None
|
||
|
||
# resume_pending freshness also uses the restart watchdog's ``last_resume_marked_at`` (the
|
||
# true interruption stamp): the transcript clock (_interruption_is_fresh) can be hours older
|
||
# for an active thread, so gating on it alone drops the recovery note — and the startup
|
||
# auto-resume turn has empty text, so the model gets a blank user message. Fresh if EITHER is.
|
||
_resume_mark_is_fresh = False
|
||
if _resume_entry is not None and getattr(_resume_entry, "resume_pending", False):
|
||
_resume_mark_is_fresh = _is_fresh_gateway_interruption(
|
||
getattr(_resume_entry, "last_resume_marked_at", None),
|
||
window_secs=_freshness_window,
|
||
)
|
||
_is_resume_pending = bool(
|
||
_resume_entry is not None
|
||
and getattr(_resume_entry, "resume_pending", False)
|
||
and (_interruption_is_fresh or _resume_mark_is_fresh)
|
||
)
|
||
_has_fresh_tool_tail = bool(
|
||
agent_history
|
||
and agent_history[-1].get("role") == "tool"
|
||
and _interruption_is_fresh
|
||
)
|
||
|
||
if _is_resume_pending:
|
||
_reason = getattr(_resume_entry, "resume_reason", None) or "restart_timeout"
|
||
# Empty message = the startup auto-resume turn from _schedule_resume_pending_sessions;
|
||
# there is no NEW user message. Interactive platforms report the restore and ask what
|
||
# next; event platforms (webhook, API server) continue the work — nobody is present to
|
||
# answer, and an acknowledgement would silently abandon the task.
|
||
_resume_adapter = self._runner._adapter_for_source(ctx.source)
|
||
_interactive_resume = bool(
|
||
getattr(_resume_adapter, "interactive_resume", True)
|
||
)
|
||
ctx.message, _persist_user_message_override = _prepare_resume_pending_message(
|
||
_reason, ctx.message, interactive=_interactive_resume,
|
||
)
|
||
elif _has_fresh_tool_tail:
|
||
_persist_user_message_override = ctx.message
|
||
ctx.message = (
|
||
"[System note: A new message has arrived. The conversation "
|
||
"history contains pending tool outputs from an interrupted turn. "
|
||
"IGNORE those pending results. Address the user's NEW message "
|
||
"below FIRST. Do NOT re-execute old tool calls from the history.]\n\n"
|
||
+ ctx.message
|
||
)
|
||
|
||
# Consume one-shot /reload-skills note (same queue pattern as CLI): prepend to the NEXT user
|
||
# message, then clear. Nothing hit the transcript out-of-band, so alternation stays intact.
|
||
_pending_notes = getattr(self._runner, "_pending_skills_reload_notes", None)
|
||
if _pending_notes and ctx.session_key and ctx.session_key in _pending_notes:
|
||
_srn = _pending_notes.pop(ctx.session_key, None)
|
||
if _srn:
|
||
ctx.message = _srn + "\n\n" + ctx.message
|
||
|
||
# Safety net: a startup auto-resume event carries empty text and relies on the resume_pending
|
||
# branch above for the recovery note. If it did not fire (freshness signals disagreed, marker
|
||
# cleared before dispatch) we must NOT hand the model a blank user turn. Restricted to
|
||
# resume_pending sessions so legitimately empty turns (caption-less image) are untouched.
|
||
if (
|
||
isinstance(ctx.message, str)
|
||
and not ctx.message.strip()
|
||
and _resume_entry is not None
|
||
and getattr(_resume_entry, "resume_pending", False)
|
||
):
|
||
_sn_reason = (
|
||
getattr(_resume_entry, "resume_reason", None) or "restart_timeout"
|
||
)
|
||
_sn_adapter = self._runner._adapter_for_source(ctx.source)
|
||
ctx.message = build_resume_recovery_note(
|
||
_sn_reason,
|
||
"",
|
||
interactive=bool(
|
||
getattr(_sn_adapter, "interactive_resume", True)
|
||
),
|
||
)
|
||
return _persist_user_message_override, _persist_user_timestamp_override
|
||
|
||
def _run_conversation_with_approval(
|
||
self, agent, agent_history, observed_group_context,
|
||
_persist_user_message_override, _persist_user_timestamp_override,
|
||
):
|
||
from gateway.run import _wrap_current_message_with_observed_context
|
||
ctx = self._ctx
|
||
# Per-session gateway approval callback: dangerous-command approval blocks the agent thread
|
||
# (mirrors CLI input()); the callback bridges sync→async to send the request immediately.
|
||
from tools.approval import (
|
||
register_gateway_notify,
|
||
reset_current_session_key,
|
||
set_current_session_key,
|
||
unregister_gateway_notify,
|
||
)
|
||
|
||
_approval_session_key = ctx.session_key or ""
|
||
_approval_session_token = set_current_session_key(_approval_session_key)
|
||
register_gateway_notify(_approval_session_key, self._approval_notify_sync)
|
||
try:
|
||
# If _prepare_inbound_message_text buffered image paths for native attachment, wrap the
|
||
# user turn as an OpenAI-style multimodal content list. Consume-and-clear so subsequent
|
||
# turns on the same runner instance don't re-attach stale images.
|
||
_native_imgs = self._runner._consume_pending_native_image_paths(ctx.session_key)
|
||
if _native_imgs:
|
||
try:
|
||
from agent.image_routing import build_native_content_parts
|
||
_parts, _skipped = build_native_content_parts(
|
||
ctx.message,
|
||
_native_imgs,
|
||
)
|
||
if _skipped:
|
||
logger.warning(
|
||
"Native image attachment: skipped %d unreadable path(s): %s",
|
||
len(_skipped), _skipped,
|
||
)
|
||
if any(p.get("type") == "image_url" for p in _parts):
|
||
_run_message: Any = _parts
|
||
else:
|
||
# All images failed to read — fall back to plain text.
|
||
_run_message = ctx.message
|
||
except Exception as _img_exc:
|
||
logger.warning(
|
||
"Native image attachment failed, falling back to text: %s",
|
||
_img_exc,
|
||
)
|
||
_run_message = ctx.message
|
||
else:
|
||
_run_message = ctx.message
|
||
|
||
_api_run_message = _wrap_current_message_with_observed_context(
|
||
_run_message,
|
||
observed_group_context,
|
||
)
|
||
_conversation_kwargs = {
|
||
"conversation_history": agent_history,
|
||
"task_id": ctx.session_id,
|
||
}
|
||
if _persist_user_message_override is not None:
|
||
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
|
||
elif observed_group_context:
|
||
_conversation_kwargs["persist_user_message"] = ctx.message
|
||
if ctx.persist_user_display_kind:
|
||
# Internal self-injected turn: type the persisted user row at turn start so UIs
|
||
# render it as a timeline notice, not a user bubble. Role/content are untouched and
|
||
# the key is stripped from provider-bound payloads in conversation_loop.
|
||
_conversation_kwargs["persist_user_display_kind"] = (
|
||
ctx.persist_user_display_kind
|
||
)
|
||
if ctx.moa_config is not None:
|
||
_conversation_kwargs["moa_config"] = ctx.moa_config
|
||
if _persist_user_timestamp_override is not None:
|
||
_conversation_kwargs["persist_user_timestamp"] = _persist_user_timestamp_override
|
||
# Thread the platform-side inbound message id onto the persisted user turn so a turn
|
||
# interrupted by a restart is recorded WITH its id — drain-window recovery dedups on
|
||
# has_platform_message_id. Uses the raw inbound id, NOT event_message_id (reply anchor).
|
||
if ctx.inbound_message_id is not None:
|
||
_conversation_kwargs["persist_user_platform_id"] = str(ctx.inbound_message_id)
|
||
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
|
||
finally:
|
||
unregister_gateway_notify(_approval_session_key)
|
||
# Cancel any pending clarify entries so blocked agent threads don't hang past the end of
|
||
# the run (interrupt, completion, gateway shutdown). Idempotent.
|
||
try:
|
||
from tools.clarify_gateway import clear_session as _clear_clarify_session
|
||
_clear_clarify_session(_approval_session_key)
|
||
except Exception:
|
||
pass
|
||
reset_current_session_key(_approval_session_token)
|
||
return result
|
||
|
||
def _finish_stream_consumer(self, result, agent_history, _stream_consumer):
|
||
ctx = self._ctx
|
||
# Canonicalize a model-emitted computer-use screenshot path at the common result boundary: the
|
||
# streaming finalizer below and the non-streaming delivery path must see the same response;
|
||
# repairing only in later media scanning leaves streaming a mangled path + rejected attachment.
|
||
if isinstance(result, dict):
|
||
_result_final = result.get("final_response")
|
||
if isinstance(_result_final, str):
|
||
result["final_response"] = repair_explicit_computer_use_media_paths(
|
||
_result_final,
|
||
result.get("messages", []),
|
||
history_offset=len(agent_history),
|
||
)
|
||
|
||
ctx.result_holder[0] = result
|
||
|
||
# Signal the stream consumer that the agent is done, passing final_response as the
|
||
# authoritative finalize payload: it includes post-stream augmentation (verifier footer,
|
||
# explainer) the accumulator never saw, so the seal delivers the TRUE final with no
|
||
# corrective send. Failed turns pass nothing — error text goes via the normal path.
|
||
if _stream_consumer is not None:
|
||
_final_for_stream = None
|
||
# Adopt ONLY a genuinely completed final: interrupt paths return {interrupted: True,
|
||
# completed: False} with a DIAGNOSTIC final_response and no failed key — adopting it
|
||
# would seal the streamed partial answer over with the diagnostic AND make
|
||
# delivered_final_matches reconcile, suppressing the gateway's own error delivery.
|
||
if (
|
||
isinstance(result, dict)
|
||
and not result.get("failed")
|
||
and not result.get("interrupted")
|
||
and result.get("completed") is not False
|
||
):
|
||
_fr = result.get("final_response")
|
||
if isinstance(_fr, str) and _fr.strip() and _fr != "(empty)":
|
||
_final_for_stream = _fr
|
||
if _final_for_stream is not None:
|
||
# Duck-type safe: test doubles / older consumers may expose a zero-arg finish(). The
|
||
# payload is an optimization, not a requirement — fall back to the bare signal.
|
||
try:
|
||
_stream_consumer.finish(_final_for_stream)
|
||
except TypeError:
|
||
_stream_consumer.finish()
|
||
else:
|
||
_stream_consumer.finish()
|
||
|
||
def _sync_session_after_run(self, agent_history):
|
||
ctx = self._ctx
|
||
# Sync session_id right after run_conversation(): compression can rotate before a follow-up
|
||
# model call fails, and the failure return below must still point at the compressed child.
|
||
agent = ctx.agent_holder[0]
|
||
_session_was_split = False
|
||
# In-place compaction (compression.in_place) compacts the transcript WITHOUT rotating the id,
|
||
# so the id-change diff below can't see it. compress_context() sets this flag on the agent; the
|
||
# gateway re-baselines (history_offset=0 + JSONL rewrite) as for a split despite unchanged id.
|
||
_compacted_in_place = bool(getattr(agent, "_last_compaction_in_place", False)) if agent else False
|
||
agent_session_id = getattr(agent, 'session_id', ctx.session_id) if agent else ctx.session_id
|
||
if agent and ctx.session_key and agent_session_id != ctx.session_id:
|
||
_session_was_split = True
|
||
logger.info(
|
||
"Session split detected: %s → %s (compression)",
|
||
ctx.session_id, agent_session_id,
|
||
)
|
||
entry = self._runner.session_store._entries.get(ctx.session_key)
|
||
_session_split_entry_persisted = False
|
||
if entry:
|
||
entry_session_id = getattr(entry, "session_id", None)
|
||
if not ctx._run_still_current():
|
||
logger.info(
|
||
"Skipping session split sync for stale run %s — "
|
||
"generation %s is no longer current",
|
||
ctx.session_key or "?",
|
||
ctx.run_generation,
|
||
)
|
||
elif entry_session_id == agent_session_id:
|
||
_session_split_entry_persisted = True
|
||
elif entry_session_id != ctx.session_id:
|
||
logger.info(
|
||
"Skipping session split sync for %s because the "
|
||
"session binding moved from %s to %s before "
|
||
"compression finished",
|
||
ctx.session_key or "?",
|
||
ctx.session_id,
|
||
entry_session_id,
|
||
)
|
||
else:
|
||
entry.session_id = agent_session_id
|
||
self._runner.session_store._save()
|
||
self._runner.session_store._record_gateway_session_peer(
|
||
agent_session_id,
|
||
ctx.session_key,
|
||
ctx.source,
|
||
)
|
||
_session_split_entry_persisted = True
|
||
|
||
# Telegram DM whose source.thread_id was lost in the session split (synthetic/recovered
|
||
# event): restore it from the binding so _thread_metadata_for_source yields the right
|
||
# message_thread_id instead of the General thread (non-fatal). Only after this run
|
||
# published its split — a stale /stop→/new predecessor must not mutate routing state.
|
||
if _session_split_entry_persisted and (
|
||
getattr(ctx.source, "platform", None) == Platform.TELEGRAM
|
||
and getattr(ctx.source, "chat_type", None) == "dm"
|
||
and getattr(ctx.source, "thread_id", None) is None
|
||
and self._runner._session_db is not None
|
||
):
|
||
try:
|
||
# run_sync is off-loop (executor); sync DB is fine.
|
||
_binding = self._runner._session_db._db.get_telegram_topic_binding_by_session(
|
||
session_id=agent_session_id,
|
||
)
|
||
if _binding and _binding.get("thread_id"):
|
||
ctx.source.thread_id = str(_binding["thread_id"])
|
||
logger.debug(
|
||
"Restored source.thread_id=%s from binding after session split %s → %s",
|
||
ctx.source.thread_id,
|
||
ctx.session_id,
|
||
agent_session_id,
|
||
)
|
||
except Exception:
|
||
logger.debug(
|
||
"Failed to restore thread_id from binding after session split",
|
||
exc_info=True,
|
||
)
|
||
if _session_split_entry_persisted:
|
||
self._runner._sync_telegram_topic_binding(
|
||
ctx.source, entry, reason="agent-run-compression",
|
||
)
|
||
|
||
effective_session_id = agent_session_id
|
||
self._runner._sync_session_model_from_agent(effective_session_id, agent)
|
||
# history_offset=0 whenever the agent's message list lost the original history prefix: rotation
|
||
# (split) OR in-place compaction. Either way the returned `messages` is the compacted set, so
|
||
# persist all of it; slicing past the pre-compaction length would drop everything.
|
||
_effective_history_offset = (
|
||
0 if (_session_was_split or _compacted_in_place) else len(agent_history)
|
||
)
|
||
return _compacted_in_place, effective_session_id, _effective_history_offset
|
||
|
||
def run_sync(self):
|
||
from gateway.run import (
|
||
_collect_auto_append_media_tags,
|
||
_current_max_iterations,
|
||
_normalize_empty_agent_response,
|
||
_sanitize_gateway_final_response,
|
||
)
|
||
ctx = self._ctx
|
||
# As a method the turn message lives on the shared TurnContext: every rebind writes
|
||
# `ctx.message`, so the outer `_run_agent_inner` body sees the update as via the closure cell.
|
||
|
||
# session_key propagates via contextvars (_set_session_env / set_current_session_key):
|
||
# concurrency-safe and inherited by tool worker threads. Deliberately do NOT write
|
||
# os.environ["HERMES_SESSION_KEY"]: it is process-global, so concurrent sessions would clobber
|
||
# each other and a tool thread with an unset contextvar would read the wrong key, misrouting
|
||
# approvals. Only the TUI slash-worker subprocess exports the env var (from its own argv).
|
||
|
||
# Map platform enum to the platform hint key the agent understands.
|
||
# Platform.LOCAL ("local") maps to "cli"; others pass through as-is.
|
||
platform_key = "cli" if ctx.source.platform == Platform.LOCAL else ctx.source.platform.value
|
||
|
||
# Combine platform context, YAML channel_prompts hint for this chat, channel_overrides
|
||
# system_prompt (or global ephemeral), and the gateway ephemeral prompt.
|
||
combined_ephemeral = ctx.context_prompt or ""
|
||
event_channel_prompt = (ctx.channel_prompt or "").strip()
|
||
if event_channel_prompt:
|
||
combined_ephemeral = (combined_ephemeral + "\n\n" + event_channel_prompt).strip()
|
||
cfg_channel_prompt = self._runner._get_system_prompt_for_channel(
|
||
ctx.source.platform,
|
||
ctx.source.chat_id or "",
|
||
thread_id=getattr(ctx.source, "thread_id", None),
|
||
parent_id=getattr(ctx.source, "parent_chat_id", None),
|
||
)
|
||
if cfg_channel_prompt:
|
||
combined_ephemeral = (combined_ephemeral + "\n\n" + cfg_channel_prompt).strip()
|
||
|
||
max_iterations = _current_max_iterations()
|
||
|
||
try:
|
||
model, runtime_kwargs = self._runner._resolve_session_agent_runtime(
|
||
source=ctx.source,
|
||
session_key=ctx.session_key,
|
||
user_config=ctx.user_config,
|
||
)
|
||
logger.debug(
|
||
"run_agent resolved: model=%s provider=%s session=%s",
|
||
model, runtime_kwargs.get("provider"), ctx.session_key or "",
|
||
)
|
||
except Exception as exc:
|
||
return {
|
||
"final_response": f"⚠️ Provider authentication failed: {exc}",
|
||
"messages": [],
|
||
"api_calls": 0,
|
||
"tools": [],
|
||
}
|
||
|
||
pr = self._runner._provider_routing
|
||
reasoning_config = self._runner._resolve_session_reasoning_config(
|
||
source=ctx.source,
|
||
session_key=ctx.session_key,
|
||
model=model,
|
||
)
|
||
self._runner._reasoning_config = reasoning_config
|
||
self._runner._service_tier = self._runner._resolve_session_service_tier(
|
||
source=ctx.source, session_key=ctx.session_key
|
||
)
|
||
(
|
||
_stream_consumer,
|
||
_stream_delta_cb,
|
||
_interim_assistant_cb,
|
||
_want_interim_messages,
|
||
) = self._setup_stream_consumer(platform_key)
|
||
|
||
turn_route = self._runner._resolve_turn_agent_config(ctx.message, model, runtime_kwargs)
|
||
agent, reused_cached_agent = self._resolve_turn_agent(
|
||
turn_route, platform_key, combined_ephemeral, max_iterations, reasoning_config, pr,
|
||
)
|
||
self._wire_turn_agent_callbacks(
|
||
agent, turn_route, reasoning_config,
|
||
_stream_delta_cb, _interim_assistant_cb, _want_interim_messages,
|
||
)
|
||
agent_history, observed_group_context, _history_media_paths = (
|
||
self._load_turn_history(agent, reused_cached_agent)
|
||
)
|
||
_persist_user_message_override, _persist_user_timestamp_override = (
|
||
self._prepare_turn_message(agent_history)
|
||
)
|
||
result = self._run_conversation_with_approval(
|
||
agent, agent_history, observed_group_context,
|
||
_persist_user_message_override, _persist_user_timestamp_override,
|
||
)
|
||
self._finish_stream_consumer(result, agent_history, _stream_consumer)
|
||
|
||
# Signal the streaming-TTS consumer that the agent is done. finish() runs on the outer
|
||
# event-loop thread after the executor returns, so early run_sync returns are also finalised.
|
||
|
||
# Return final response, or a message if something went wrong
|
||
final_response = result.get("final_response")
|
||
|
||
# Extract actual token counts from the agent instance used for this run
|
||
_last_prompt_toks = 0
|
||
_input_toks = 0
|
||
_output_toks = 0
|
||
_context_length = 0
|
||
_agent = ctx.agent_holder[0]
|
||
if _agent and hasattr(_agent, "context_compressor"):
|
||
_last_prompt_toks = getattr(_agent.context_compressor, "last_prompt_tokens", 0)
|
||
_input_toks = getattr(_agent, "session_prompt_tokens", 0)
|
||
_output_toks = getattr(_agent, "session_completion_tokens", 0)
|
||
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
|
||
_resolved_model = getattr(_agent, "model", None) if _agent else None
|
||
|
||
_compacted_in_place, effective_session_id, _effective_history_offset = (
|
||
self._sync_session_after_run(agent_history)
|
||
)
|
||
|
||
if not final_response:
|
||
final_response = _normalize_empty_agent_response(
|
||
result, final_response or "", history_len=len(agent_history),
|
||
)
|
||
final_response = _sanitize_gateway_final_response(ctx.source.platform, final_response)
|
||
if not final_response:
|
||
final_response = f"⚠️ {result['error']}" if result.get("error") else ""
|
||
return {
|
||
"final_response": final_response,
|
||
"messages": result.get("messages", []),
|
||
"api_calls": result.get("api_calls", 0),
|
||
"failed": result.get("failed", False),
|
||
# Sibling of the non-empty-response return below: the classifier's failure_reason
|
||
# must survive the empty-response path too, or downstream consumers (TUI billing,
|
||
# transient-failure persistence) lose the structured reason when no text was produced.
|
||
"failure_reason": result.get("failure_reason"),
|
||
"partial": result.get("partial", False),
|
||
"completed": result.get("completed"),
|
||
"interrupted": result.get("interrupted", False),
|
||
"interrupt_message": result.get("interrupt_message"),
|
||
"error": result.get("error"),
|
||
"compression_exhausted": result.get("compression_exhausted", False),
|
||
"compression_deferred": result.get("compression_deferred", False),
|
||
"tools": ctx.tools_holder[0] or [],
|
||
"history_offset": _effective_history_offset,
|
||
"compacted_in_place": _compacted_in_place,
|
||
"session_id": effective_session_id,
|
||
"last_prompt_tokens": _last_prompt_toks,
|
||
"input_tokens": _input_toks,
|
||
"output_tokens": _output_toks,
|
||
"model": _resolved_model,
|
||
"context_length": _context_length,
|
||
}
|
||
|
||
# Append MEDIA:<path> tags from tool results (e.g. TTS) that the model's final text omits, so
|
||
# extract_media() delivers each file once. Scope to THIS turn (slice at ``len(agent_history)``)
|
||
# so a stale MEDIA: path from an earlier turn doesn't ride a later text-only reply; dedup
|
||
# against _history_media_paths is the secondary guard — and the sole one on the fallback
|
||
# branch when mid-run compression shrank the list below the history length.
|
||
if "MEDIA:" not in final_response:
|
||
media_tags, has_voice_directive = _collect_auto_append_media_tags(
|
||
result.get("messages", []),
|
||
history_offset=len(agent_history),
|
||
history_media_paths=_history_media_paths,
|
||
)
|
||
|
||
if media_tags:
|
||
seen = set()
|
||
unique_tags = []
|
||
for tag in media_tags:
|
||
if tag not in seen:
|
||
seen.add(tag)
|
||
unique_tags.append(tag)
|
||
if has_voice_directive:
|
||
unique_tags.insert(0, "[[audio_as_voice]]")
|
||
final_response = final_response + "\n" + "\n".join(unique_tags)
|
||
|
||
# Auto-titling runs at TURN START (agent/turn_context.py) from the user's message alone, so a
|
||
# failed/interrupted turn is still titled. Thread-rename callbacks are attached as
|
||
# `_on_session_title` before the run because the titler fires from the turn prologue.
|
||
|
||
return {
|
||
"final_response": final_response,
|
||
"last_reasoning": result.get("last_reasoning"),
|
||
"messages": ctx.result_holder[0].get("messages", []) if ctx.result_holder[0] else [],
|
||
"api_calls": ctx.result_holder[0].get("api_calls", 0) if ctx.result_holder[0] else 0,
|
||
"failed": ctx.result_holder[0].get("failed", False) if ctx.result_holder[0] else False,
|
||
"failure_reason": (
|
||
ctx.result_holder[0].get("failure_reason") if ctx.result_holder[0] else None
|
||
),
|
||
"completed": ctx.result_holder[0].get("completed") if ctx.result_holder[0] else None,
|
||
"interrupted": ctx.result_holder[0].get("interrupted", False) if ctx.result_holder[0] else False,
|
||
"partial": ctx.result_holder[0].get("partial", False) if ctx.result_holder[0] else False,
|
||
"error": ctx.result_holder[0].get("error") if ctx.result_holder[0] else None,
|
||
"interrupt_message": ctx.result_holder[0].get("interrupt_message") if ctx.result_holder[0] else None,
|
||
"compression_exhausted": (
|
||
ctx.result_holder[0].get("compression_exhausted", False)
|
||
if ctx.result_holder[0] else False
|
||
),
|
||
# Soft lock-contention defer: distinct from compression_exhausted so the gateway never
|
||
# auto-resets a session that a concurrent compressor is about to shrink.
|
||
"compression_deferred": (
|
||
ctx.result_holder[0].get("compression_deferred", False)
|
||
if ctx.result_holder[0] else False
|
||
),
|
||
"tools": ctx.tools_holder[0] or [],
|
||
"history_offset": _effective_history_offset,
|
||
"compacted_in_place": _compacted_in_place,
|
||
"last_prompt_tokens": _last_prompt_toks,
|
||
"input_tokens": _input_toks,
|
||
"output_tokens": _output_toks,
|
||
"model": _resolved_model,
|
||
"context_length": _context_length,
|
||
"session_id": effective_session_id,
|
||
"response_previewed": result.get("response_previewed", False),
|
||
"response_transformed": result.get("response_transformed", False),
|
||
# Pass through agent_persisted so the persistence block above can tell whether the codex
|
||
# app-server path self-persisted (it didn't — see codex_runtime.py); default True keeps the
|
||
# skip-db behaviour for the standard runtime.
|
||
"agent_persisted": (ctx.result_holder[0].get("agent_persisted", True) if ctx.result_holder[0] else True),
|
||
}
|