butler/gateway/message_pipelines.py
Rama main · réplica de lectura del repositorio original.
"""R1-6 inbound pipeline phases for ``ButlerMessageHandler.handle_message``.
Audit source: ``docs/reviews/project-deep-audit-2026-06-r1to8.md`` §R1-6
The original ``handle_message`` (243-617, ~334 non-blank lines) was a
god method that mixed session resolution, text transformation, guard
checks (io_guardrail, human_gate, injection_guard, injection_llm,
bot_loop_guard, two_phase_confirm), pre-dispatch rewrites,
idempotency, session initializing, queue dispatch, and reply
admission — all in one linear flow with no test seams.
This module exposes the **pre-session** pipeline as composable phase
functions. Each takes the handler instance plus the per-turn state it
needs and returns either:
* ``None`` — pipeline should continue to the next phase
* ``str`` — terminal user-facing reply (early return)
Post-condition: every phase helper is a thin orchestrator under 50
source lines (R1-5.2 size contract — see
``tests/test_message_handler_split.py``).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Optional, cast
if TYPE_CHECKING:
from butler.gateway.message_handler import ButlerMessageHandler
logger = logging.getLogger(__name__)
from butler.core.message_ir import inbound_text_from_gateway
from butler.core.session_transcript import append_transcript_entry
from butler.core.steer import format_steer_gateway_reply, is_run_active, steer
from butler.core.two_phase_confirm import cancel_pending_unless_confirm, try_execute_pending_confirm
from butler.gateway.bot_loop_guard import record_and_should_suppress
from butler.gateway.handler_helpers import (
_is_prequeue_interrupt_command,
apply_auto_continue_rewrite,
)
from butler.gateway.hooks import apply_pre_gateway_dispatch
from butler.resilience.inbound_idempotency import check_and_reserve_inbound, record_duplicate_skip
from butler.gateway.message_pipelines_fail_closed import (
apply_human_gate_fail_closed,
apply_injection_guard_fail_closed,
apply_injection_llm_fail_closed,
apply_io_guardrail_fail_closed,
)
from butler.resilience.message_queue import (
enqueue_inbound,
format_queued_ack,
pending_count,
)
from butler.gateway.owner_gate import is_gateway_owner, owner_required_message
from butler.gateway.queue_settings import get_queue_mode, session_drop_policy
from butler.gateway.reply_admission import try_admit
from butler.gateway.session_lifecycle import (
format_initializing_ack,
session_initializing_enabled,
try_enter_session,
)
from butler.mcp.profiles import mcp_profiles_enabled, select_profile_for_text, set_session_profile
from butler.session.keys import chat_id_from_session_key
from butler.skills.similarity import _ensure_jieba
from butler.core.best_effort import safe_best_effort
def _record_injection_transcript(
session_key: str,
entry_type: str,
score: float,
text: str,
*,
label: str,
) -> None:
def _run() -> None:
append_transcript_entry(
session_key,
entry_type,
{"score": score, "preview": text[:120]},
)
safe_best_effort(_run, label=label, default=None)
def _warm_session_skills(orchestrator: Any) -> None:
def _jieba() -> None:
_ensure_jieba()
safe_best_effort(_jieba, label="message_pipelines.jieba_warmup", default=None)
mgr = getattr(orchestrator, "_skill_manager", None)
if mgr is not None:
mgr.list_skills()
# ---------------------------------------------------------------------------
# Phase 1 — resolve the canonical ``platform:chat_id:project`` session key.
# ---------------------------------------------------------------------------
def _phase_resolve_session_key(
handler: "ButlerMessageHandler",
*,
platform: str,
external_id: str | None,
session_key: str | None,
) -> str:
"""Phase: normalize inbound args to a canonical session key."""
return cast(
str,
handler.resolve_session_key(
platform=platform,
external_id=external_id,
session_key=session_key,
),
)
# ---------------------------------------------------------------------------
# Phase 2 — apply platform-specific text transformation (message_ir).
# ---------------------------------------------------------------------------
def _phase_transform_inbound_text(
text: str,
*,
platform: str,
external_id: str | None,
session_key: str,
) -> str:
"""Phase: delegate to ``message_ir.inbound_text_from_gateway``.
Wrapped in try/except to preserve the original fail-open behavior:
if the transformer is missing or raises, the raw text is used.
"""
def _run() -> str:
return cast(
str,
inbound_text_from_gateway(
text,
platform=platform,
external_id=external_id,
session_key=session_key,
),
)
transformed = safe_best_effort(
_run,
label="message_pipelines.inbound_text_transform",
default=None,
)
return cast(str, transformed if transformed is not None else text)
# ---------------------------------------------------------------------------
# Phase 3 — select the MCP profile for the session based on the text.
# ---------------------------------------------------------------------------
def _phase_apply_mcp_profile(text: str, session_key: str) -> None:
"""Phase: select and bind the MCP profile for this session."""
def _run() -> None:
if mcp_profiles_enabled() and text.strip():
set_session_profile(session_key, select_profile_for_text(text))
safe_best_effort(_run, label="message_pipelines.mcp_profile", default=None)
# ---------------------------------------------------------------------------
# Phase 4 — io guardrail (fail-closed on exception).
# ---------------------------------------------------------------------------
def _phase_apply_io_guardrail(text: str) -> Optional[str]:
"""Phase: io guardrail tripwire. Returns a blocking reply or None."""
return cast(Optional[str], apply_io_guardrail_fail_closed(text))
# ---------------------------------------------------------------------------
# Phase 5 — human gate (with owner check on gate reply).
# ---------------------------------------------------------------------------
def _phase_apply_human_gate(
text: str,
session_key: str,
*,
platform: str,
external_id: str | None,
) -> Optional[str]:
"""Phase: human gate — blocks (with owner check) if pending review."""
return cast(
Optional[str],
apply_human_gate_fail_closed(
text,
session_key,
platform=platform,
external_id=external_id,
),
)
# ---------------------------------------------------------------------------
# Phase 6 — injection risk scoring (heuristic).
# ---------------------------------------------------------------------------
def _phase_apply_injection_guard(
text: str, session_key: str,
) -> tuple[str, Optional[str]]:
"""Phase: heuristic injection scoring + adversarial marker rewrite.
Returns ``(new_text, block_or_None)``. When ``block`` is set,
the caller must short-circuit and return it as the final reply
(fail-closed semantics from the original ``handle_message``).
"""
return cast(
tuple[str, Optional[str]],
apply_injection_guard_fail_closed(
text,
session_key,
record_injection_transcript=_record_injection_transcript,
),
)
# ---------------------------------------------------------------------------
# Phase 7 — LLM-based injection gate (with bypass/pending handling).
# ---------------------------------------------------------------------------
def _phase_apply_injection_llm(text: str, session_key: str) -> Optional[str]:
"""Phase: LLM-driven injection gate. Returns a blocking reply or None."""
return cast(
Optional[str],
apply_injection_llm_fail_closed(
text,
session_key,
record_injection_transcript=_record_injection_transcript,
),
)
# ---------------------------------------------------------------------------
# Phase 8 — bot loop guard (group chat echo loop prevention).
# ---------------------------------------------------------------------------
def _phase_apply_bot_loop_guard(
text: str,
session_key: str,
*,
external_id: str | None,
) -> Optional[str]:
"""Phase: suppress group-chat bot echo loops."""
def _run() -> Optional[str]:
chat_id = session_key.split(":")[-1] if ":" in session_key else session_key
suppress, _reason = record_and_should_suppress(
chat_id=chat_id,
sender_id=str(external_id or ""),
text=text,
)
if suppress:
return "(已忽略:群聊 bot 互回复环防护)"
return None
return cast(
Optional[str],
safe_best_effort(_run, label="message_pipelines.bot_loop_guard", default=None),
)
# ---------------------------------------------------------------------------
# Phase 9 — two-phase confirm (with owner check on confirm reply).
# ---------------------------------------------------------------------------
def _phase_apply_two_phase_confirm(
text: str,
session_key: str,
*,
platform: str,
external_id: str | None,
) -> Optional[str]:
"""Phase: dispatch / confirm / cancel two-phase confirm flow."""
def _run() -> Optional[str]:
pending_reply = try_execute_pending_confirm(text, session_key=session_key)
if pending_reply is not None:
if not is_gateway_owner(platform=platform, external_id=external_id):
return cast(str, owner_required_message())
return cast(str, pending_reply)
cancel_note = cancel_pending_unless_confirm(text, session_key=session_key)
if cancel_note:
return f"{cancel_note}\n\n{text}"
return None
return cast(
Optional[str],
safe_best_effort(
_run,
label="message_pipelines.two_phase_confirm",
default=None,
),
)
# ---------------------------------------------------------------------------
# Phase 10 — prequeue interrupt command.
# ---------------------------------------------------------------------------
def _phase_apply_prequeue_interrupt(
text: str,
session_key: str,
handler: "ButlerMessageHandler",
) -> Optional[str]:
"""Phase: prequeue interrupt command (e.g. /停止)."""
if _is_prequeue_interrupt_command(text):
return cast(str, handler._format_prequeue_interrupt_reply(session_key))
return None
# ---------------------------------------------------------------------------
# Phase 11 — pre-dispatch text rewrites (auto_continue + pre_gateway_dispatch).
# ---------------------------------------------------------------------------
def _phase_apply_pre_dispatch_rewrites(
text: str,
session_key: str,
*,
platform: str,
) -> Optional[str]:
"""Phase: rewrite text via auto_continue and pre_gateway_dispatch hooks.
Returns ``None`` to mean "use original text", ``""`` to mean
"drop this turn", or a non-empty string to mean "use as new text".
Note: the original code did **not** wrap ``apply_pre_gateway_dispatch``
in try/except — a hook failure propagated. We preserve that
fail-loud behavior here on purpose.
"""
continued = apply_auto_continue_rewrite(session_key, text)
if continued:
text = continued
rewritten = apply_pre_gateway_dispatch(text, session_key=session_key, platform=platform)
if rewritten is None:
return None
if not rewritten.strip():
return ""
return cast(Optional[str], rewritten)
# ---------------------------------------------------------------------------
# Phase 12 — inbound idempotency (Sprint 14 REL-11-1 reservation).
# ---------------------------------------------------------------------------
def _phase_apply_idempotency(
text: str,
session_key: str,
*,
external_id: str | None,
) -> tuple[Optional[str], bool, str]:
"""Phase: idempotency reservation.
Returns ``(early_reply_or_None, reserved, inbound_id)``.
``reserved=True`` means the caller is responsible for calling
``complete_inbound`` in the ``finally`` block.
"""
def _run() -> tuple[Optional[str], bool, str]:
inbound_id = str(external_id or "").strip()
if inbound_id and inbound_id == chat_id_from_session_key(session_key):
inbound_id = ""
_idem = check_and_reserve_inbound(
session_key,
inbound_id,
text_preview=text[:80],
)
if _idem.skip:
record_duplicate_skip(
session_key,
reason=_idem.reason,
external_id=inbound_id,
preview=text[:80],
)
return (_idem.user_reply or "(重复消息已忽略。)"), False, inbound_id
return None, True, inbound_id
return cast(
tuple[Optional[str], bool, str],
safe_best_effort(
_run,
label="message_pipelines.idempotency",
default=(None, False, ""),
),
)
# ---------------------------------------------------------------------------
# Phase 13 — session initializing (warmup + queue while spinning up).
# ---------------------------------------------------------------------------
def _phase_apply_session_initializing(
text: str,
session_key: str,
*,
platform: str,
external_id: str | None,
orchestrator: Any,
) -> Optional[str]:
"""Phase: session-initializing warmup. Returns early ack or None."""
def _run() -> Optional[str]:
if not session_initializing_enabled():
return None
state = try_enter_session(session_key, lambda: _warm_session_skills(orchestrator))
if state != "queued":
return None
if enqueue_inbound(
session_key,
text,
platform=platform,
external_id=external_id or "",
):
return cast(
str,
format_initializing_ack(pending=pending_count(session_key)),
)
return cast(str, format_initializing_ack())
return cast(
Optional[str],
safe_best_effort(
_run,
label="message_pipelines.session_initializing",
default=None,
),
)
# ---------------------------------------------------------------------------
# Phase 14 — queue inbound (steer / interrupt / collect / followup).
# ---------------------------------------------------------------------------
def _phase_apply_queue_inbound(
text: str,
session_key: str,
*,
platform: str,
external_id: str | None,
handler: "ButlerMessageHandler",
) -> Optional[str]:
"""Phase: enqueue or steer per queue_mode; return ack or None.
Note: the original code did **not** wrap the queue logic in
try/except — a queue failure propagated. We preserve that
fail-loud behavior here on purpose.
"""
if not handler._should_queue_inbound(session_key, text):
return None
mode = get_queue_mode(session_key)
if mode == "steer":
if is_run_active(session_key) and steer(text, session_key=session_key):
return cast(str, format_steer_gateway_reply(accepted=True, active=True))
return None
if mode == "interrupt":
handler._format_prequeue_interrupt_reply(session_key)
return None
if enqueue_inbound(
session_key,
text,
platform=platform,
external_id=external_id or "",
):
return cast(
str,
format_queued_ack(
pending=pending_count(session_key),
session_key=session_key,
),
)
if session_drop_policy(session_key) == "new":
return "队列已满,最新消息未入队。可发 /queue 调整 cap 或 /诊断 查看。"
return cast(
str,
format_queued_ack(pending=pending_count(session_key), session_key=session_key),
)
# ---------------------------------------------------------------------------
# Phase 15 — reply admission (single-flight gate).
# ---------------------------------------------------------------------------
def _phase_apply_admission(text: str, session_key: str) -> Optional[Any]:
"""Phase: try_admit. Returns admission token (caller releases) or None."""
def _run() -> Any:
return try_admit(session_key)
return safe_best_effort(
_run,
label="message_pipelines.reply_admission",
default=True,
)
def queue_inbound_for_admission_failure(
text: str,
session_key: str,
*,
platform: str,
external_id: str | None,
) -> str:
"""When ``try_admit`` returns ``None`` we either enqueue or reply."""
def _run() -> Optional[str]:
if enqueue_inbound(
session_key,
text,
platform=platform,
external_id=external_id or "",
):
return cast(
str,
format_queued_ack(
pending=pending_count(session_key),
session_key=session_key,
),
)
return None
queued = safe_best_effort(
_run,
label="message_pipelines.admission_failure_enqueue",
default=None,
)
if queued:
return cast(str, queued)
return "会话处理中,请稍候…"