deer-flow/backend/app/channels/run_policy.py
Daoyuan Li d2b5f884e3
fix(channels): buffer GitHub follow-ups during busy runs (#4133)
Queue comments received while a run is active, then submit one deduplicated follow-up after it finishes. Failed drains are requeued and watcher tasks stop cleanly with the channel manager.
2026-07-24 22:41:07 +08:00

119 lines
6.2 KiB
Python

"""Per-channel run policy registry.
Holds the global ``CHANNEL_RUN_POLICY`` map and its :class:`ChannelRunPolicy`
descriptor. Split into its own module so channels can register their own
policy entries (typically as a side-effect of importing their package)
without creating a circular dependency on :mod:`app.channels.manager`.
The dispatch path in :class:`app.channels.manager.ChannelManager` looks
up policy entries by ``msg.channel_name`` and applies them after
``_resolve_run_params``.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from app.channels.message_bus import InboundMessage
@dataclass(frozen=True, slots=True)
class ChannelRunPolicy:
"""Per-channel knobs applied by :meth:`ChannelManager._apply_channel_policy`.
Webhook-driven channels (GitHub today; others later) need four
things the generic interactive-chat path does not: a higher
``recursion_limit`` for autonomous long runs, suppression of
``ask_clarification`` (no human is synchronously present), a
credentials provider that mints platform tokens for the agent, and
an opt-out from the per-sender bound-identity gate (authenticity is
enforced at the webhook route by HMAC, and there is no equivalent
of a per-user ``/connect`` handshake to perform).
Declaring all four on one dataclass keeps the channel's run
behavior in a single discoverable place and turns "add a new
webhook channel" into a one-row registration instead of touching
multiple separate methods on the manager.
Attributes:
is_interactive: When False, the manager sets
``run_context["disable_clarification"] = True`` so
``ClarificationMiddleware`` returns a "proceed with best
judgment" ToolMessage instead of interrupting via
``Command(goto=END)``. Defaults to True (the safe default
for an IM channel).
default_recursion_limit: When set, the manager raises
``run_config["recursion_limit"]`` to ``max(existing,
limit)``. None leaves the global default (100) untouched —
interactive chat turns don't need 250 super-steps.
credentials_provider: Optional async hook that mutates
``run_context`` with platform-specific credentials. Called
after ``_resolve_run_params``. Exceptions are caught and
logged so a credential failure degrades gracefully (agent
runs read-only) instead of dropping the delivery.
requires_bound_identity: When False, the manager skips the
per-sender bound-identity gate (``_get_bound_identity_rejection``)
for this channel even when ``channel_connections.enabled`` is
on. Webhook-authenticated channels (GitHub) have no
per-sender ``/connect`` handshake — authenticity is enforced
by HMAC at the webhook route, and the binding from "sender"
to DeerFlow user is encoded in the agent's ``config.yaml``
ownership, not in the channel-connections table. Defaults to
True (the safe default for an interactive IM channel).
fire_and_forget: When True, the manager schedules the run with
``runs.create`` (returns immediately once the run is
``pending``) instead of ``runs.wait`` (which keeps an HTTP
stream open for the entire run lifetime). Channels that do
their own outbound during the run — e.g. GitHub, where the
agent posts to the issue/PR via the ``gh`` CLI in its
sandbox — don't need the manager to ferry a final state
back. Eliminates the SDK's 300s ``httpx.ReadTimeout`` on
runs that legitimately take more than 5 minutes, and the
false "internal error" outbound that follows when it
fires. Defaults to False (the safe default for an
interactive IM channel that depends on the manager to
publish the agent's reply).
serialize_thread_runs: When True, the manager serializes
same-thread inbound turns for this channel instead of
surfacing the runtime's generic busy-thread error. This is
useful for chat surfaces like Feishu topics where rapid
follow-up messages should queue behind the active turn while
unrelated DeerFlow threads continue concurrently. Defaults
to False so existing channels keep the runtime's native
multitask behavior unless they opt in explicitly.
buffer_followups_on_busy: When True, a ``ConflictError`` on the
``fire_and_forget`` dispatch path (see
:meth:`ChannelManager._handle_chat_on_thread`) does more than
log + reply with the generic busy message: the triggering
message is appended to a per-thread follow-up buffer, and a
background watcher subscribes to the active run's
``StreamBridge`` stream so it can coalesce the buffer into a
follow-up run as soon as that run ends. This targets
``fire_and_forget`` channels whose ``send`` is otherwise the
only feedback a busy sender gets (e.g. GitHub, where ``send``
is log-only) — without it, a concurrent comment is silently
dropped from the sender's point of view. Defaults to False so
channels that have not opted in keep the exact old
silent-drop-with-log behavior; see
``app.gateway.github.run_policy`` for GitHub's opt-in.
"""
is_interactive: bool = True
default_recursion_limit: int | None = None
credentials_provider: Callable[[InboundMessage, dict[str, Any]], Awaitable[None]] | None = None
requires_bound_identity: bool = True
fire_and_forget: bool = False
serialize_thread_runs: bool = False
buffer_followups_on_busy: bool = False
# Channel name → policy. Channels absent from this map fall through to
# the policy default (an interactive IM channel with no credential
# plumbing) — which is what every IM channel had before GitHub. Webhook
# channels register their entry at package-import time (see
# ``app.gateway.github.run_policy``).
CHANNEL_RUN_POLICY: dict[str, ChannelRunPolicy] = {}