Storm 061a7dfc14
feat(channels): select the model per conversation via /model (#6534)
* feat(channels): select the model per conversation via /model

IM channels could list models with /models but had no way to choose one:
the model for a conversation came only from channel/session config or the
server default, so a user who needed a stronger or cheaper model for one
task had to reconfigure the whole channel or move to the web UI.

- /model <name> pins a model to the current conversation, validated
  against the caller-visible /api/models list (list membership doubles
  as the authorization check), persisted in thread metadata so it
  survives a Gateway restart and applies from the next message in
  place — unlike /agent use, which must start a new conversation.
- The pin is written to all three runtime carriers (run context,
  configurable, context) after the session layers merge, so it wins
  over channel/session configured models; like any explicitly
  requested model it also takes precedence over a Custom Agent's
  configured model.
- /model (no args) reports the effective model and its source through
  one resolver that mirrors the run merge order (default -> channel ->
  user, context carrier over configurable, both session-config
  carriers scanned); /model default clears the pin; /models names the
  pinned model in a footer.
- Pin lookups degrade instead of failing messages: the status query
  reports "Current model: unknown" on a lookup failure (matching the
  /models footer), and the reuse-thread preload leaves the in-process
  cache untouched so the next turn retries a transient threads.get.
- Reserve "model" in the slash-skill reserved-name contract (backend,
  contract fixture, frontend), register the Telegram handler, and
  document the command in /help, README, and README_zh.

* fix(channels): fence model pin cache and mirror runtime model resolution

Review follow-up for /model:

- Serialize cold pin loads with persist+cache publication under a
  per-manager lock, so a slow threads.get that captured pre-pin
  metadata can never overwrite a concurrently committed selection
  (commands are not run-serialized).
- _resolve_configured_model_name now replicates the run path's shallow
  session merges and applies the real runtime precedence —
  config.context (preserved by build_run_config, unbeatable by the
  setdefault-filled body context) > top-level context > configurable —
  instead of scanning per-layer carriers; a later layer replacing a
  nested carrier wholesale no longer leaks a stale value into the
  status reply.
- /api/models resolves the trusted bound owner for internal channel
  calls and filters the list with the owner's role, so /model
  selection gates on the same principal that run admission admits for
  that account.

Tests: pin-write-vs-in-flight-load race, status compared against the
real merged runtime config (parametrized across carriers),
nested-carrier replacement, and owner-role filtering with the real
RBAC provider; each verified red by reverting exactly its guard.

* fix(channels): share model resolvers with the run path and harden the pin lifecycle

The /model status reply re-implemented the session-layer merge and the
Gateway carrier precedence, and it ignored the custom-agent model
fallback — on a thread pinned to a custom agent whose config sets
``model:`` it reported "server default" while the run actually used
the agent's model. Review also found the pin lock spanned a network
call and a stale pin bricked its conversation behind the generic
error fallback.

- Extract ``_select_carried_model_name`` as the single carrier-precedence
  rule; the status reply reuses the run path's exact ``_merge_dicts``
  layer shapes, so a future layering change has one place to touch.
- Extract ``_resolve_message_assistant_id`` / ``_resolve_assistant_id``
  and share the assistant precedence chain between the run path and
  the status reply.
- Fall back to the resolved custom agent's configured ``model`` when no
  session layer carries one, mirroring the agent factory's
  ``_resolve_model_name(requested or agent_model_name)``; the reply now
  distinguishes "(from channel configuration)" and "(from agent
  configuration)".
- Run the cold ``threads.get`` in ``_load_thread_model`` outside
  ``_model_pin_lock``; only cache publication takes the lock, with a
  post-GET re-check so an in-flight /model write still wins over the
  older snapshot.
- Detect the run-admission allowlist rejection in ``_handle_message``
  and recover: clear the stale pin (durable write plus cache under the
  lock) and reply with the reset and the /model recovery instead of
  the generic internal-error fallback.
- Document /model and the ``channel_model_name`` pin semantics in
  backend/app/channels/AGENTS.md.

* fix(channels): fence stale-pin recovery and cover streaming admission failures

Review round 4 found the stale-pin recovery itself unsafe and
incomplete:

- Recovery cleared whatever pin was current when the delayed
  admission rejection arrived, so a rejection for an old pin could
  erase a replacement selection committed mid-flight (and tell the
  user the new model was unavailable). ``_stale_pin_recovery`` now
  takes the failed run's own selection (``expected_pin``, read from
  its run context) and clears only while that selection is still
  current under the lock; a replaced or already-reset selection gets
  a non-destructive reply instead.
- Feishu/Telegram/DingTalk streaming turns never reached the recovery
  hook because ``_handle_streaming_chat`` swallows start_run's 400
  into its terminal-error reply. The recovery now runs in the
  streaming terminal-error path too, delivered as the single final
  outbound with the same dedupe cleanup; blocking, streaming, and
  fire-and-forget run-creation paths all handle the rejection at the
  site that knows the submitted selection.

* docs(channels): fit the /model entry within the agent-guidance size budget

The base file sits one byte under the AGENTS.md hard limit, so
documenting /model requires net-zero growth: keep the command-inventory
addition and a compact pin-semantics sentence, and losslessly tighten
the surrounding prose (the file was already at the limit).

* fix(channels): apply the review suggestions on /model replies

Three non-blocking findings from the latest review:

- The /models pin footer decorated the failure string too, advertising a
  pin next to an error that lists nothing. The command now fetches the
  listing through ``_fetch_gateway_json`` and appends the footer only on
  a successful fetch.
- The /model status reply labeled every session-layer model "from
  channel configuration", misreporting user- and default-layer models.
  The label is now "session configuration" for every session layer.
- Stale-pin recovery replies read as if the turn went through, but the
  run was rejected before starting and the triggering message never
  reached the agent. All recovery variants now end with an explicit
  "Your message was not processed — please send it again."

* fix(channels): recover stale model pins on the follow-up drain path

The drain is the fourth run-creation path and the only one without
stale-pin recovery: a /model pin that left the allowlist while follow-ups
were buffered made every drained runs.create fail admission, requeueing
the batch into the same 400 on every cycle with no user-facing recovery —
contradicting the documented "stale pins self-clear" invariant.

- Compute expected_pin from the drain's run context (mirroring
  _handle_chat_on_thread, initialized None since _resolve_run_params is
  itself inside the guarded try) and run _stale_pin_recovery on drain
  failure before requeueing; the pin self-clears and the requeued batch
  succeeds on the next cycle.
- Pass the caller-authoritative thread_id into _stale_pin_recovery:
  every call site already holds it, so recovery never re-derives it from
  the message — a store-mapping rewrite between dispatch and recovery
  can never clear the wrong conversation's pin, and the lookup's
  failure mode (and its guard) disappears entirely.
- Return StalePinRecovery(text, resolved) so requeueing callers can
  tell a cleared pin from a repeat failure: the drain logs INFO when
  the next cycle is free of this rejection and WARNING when recovery
  could not remove it (persist failed, or the selection came from
  session configuration, which still carries it) — no silent infinite
  requeue loop. Drain logs carry structured facts only: the reply text
  ends with a "please resend" note that would double-process buffered
  messages retried automatically (GitHub, the only buffering channel,
  sends log-only anyway).
- Reword the config-carried recovery reply: with no pin held, the
  rejected selection came from session configuration — name that source
  and the fix instead of claiming a default takeover that would not
  happen.
- Correct the _load_thread_model lock docstring (writes deliberately
  hold _model_pin_lock across threads.update) and read the agent config
  for /model status off the event loop via asyncio.to_thread, with a
  Blockbuster-gated blocking_io regression test.
- Pin the under-lock re-check in _stale_pin_recovery with a concurrency
  test (a /model committed during the lock-wait window wins; recovery
  persists nothing), mutation-verified — the drain is the only caller
  running on a background task without the serialized-run lock, so it is
  the path most exposed to that window.

* Revert "fix(channels): recover stale model pins on the follow-up drain path"

This reverts commit 0b4652c689afb56791e5e670901c97a4bf44a156. The drain
recovery work moves to a dedicated PR stacked on this branch, keeping #6534
scoped to the /model feature itself.

* fix(channels): recover stale model pins on the follow-up drain path

The drain is the fourth run-creation path and the only one without
stale-pin recovery: a /model pin that left the allowlist while follow-ups
were buffered made every drained runs.create fail admission, requeueing
the batch into the same 400 on every cycle with no user-facing recovery —
contradicting the documented "stale pins self-clear" invariant.

- Compute expected_pin from the drain's run context (mirroring
  _handle_chat_on_thread, initialized None since _resolve_run_params is
  itself inside the guarded try) and run _stale_pin_recovery on drain
  failure before requeueing; the pin self-clears and the requeued batch
  succeeds on the next cycle.
- Pass the caller-authoritative thread_id into _stale_pin_recovery:
  every call site already holds it, so recovery never re-derives it from
  the message — a store-mapping rewrite between dispatch and recovery
  can never clear the wrong conversation's pin, and the lookup's
  failure mode (and its guard) disappears entirely.
- Return StalePinRecovery(text, resolved) so requeueing callers can
  tell a cleared pin from a repeat failure: the drain logs INFO when
  the next cycle is free of this rejection and WARNING when recovery
  could not remove it (persist failed, or the selection came from
  session configuration, which still carries it) — no silent infinite
  requeue loop. Drain logs carry structured facts only: the reply text
  ends with a "please resend" note that would double-process buffered
  messages retried automatically (GitHub, the only buffering channel,
  sends log-only anyway).
- Reword the config-carried recovery reply: with no pin held, the
  rejected selection came from session configuration — name that source
  and the fix instead of claiming a default takeover that would not
  happen.
- Correct the _load_thread_model lock docstring (writes deliberately
  hold _model_pin_lock across threads.update) and read the agent config
  for /model status off the event loop via asyncio.to_thread, with a
  Blockbuster-gated blocking_io regression test.
- Pin the under-lock re-check in _stale_pin_recovery with a concurrency
  test (a /model committed during the lock-wait window wins; recovery
  persists nothing), mutation-verified — the drain is the only caller
  running on a background task without the serialized-run lock, so it is
  the path most exposed to that window.

(cherry picked from commit 0b4652c689afb56791e5e670901c97a4bf44a156)

* fix(channels): apply the third review round on /model command replies

- Delete the dead kind == "models" branch in _fetch_gateway: /models has
  rendered the listing inline since the pin-footer fix, so the helper's
  copy was unreachable and had already diverged (unguarded m["name"], no
  pin footer). The internal-auth-header test now drives the surviving
  /memory kind.
- Guard the inline /models listing comprehension like the
  _handle_model_command sibling (isinstance(m, Mapping) and
  isinstance(m.get("name"), str)), so a malformed payload entry can no
  longer turn the command into the generic internal-error reply;
  regression: test_models_command_skips_malformed_entries.
- Drop the unused user parameter from _resolve_bound_owner_for_model_list:
  the resolved owner comes solely from the trusted header, and the
  parameter implied the caller's own principal shaped the filter.

* fix(channels): apply the fourth review round on the /model lifecycle

- Preserve fresh model pins across cache eviction: a cold threads.get
  racing a successful /model write could still publish its stale
  snapshot after clear-on-pressure evicted the fresh entry — the
  in-cache re-check cannot see an eviction. The model-pin cache now
  carries a write generation; a load whose generation moved during the
  GET refetches once instead of publishing over the committed
  selection. Regression:
  test_fresh_model_pin_survives_cache_eviction_during_cold_load
  (blocked GET, write, eviction, then the refetch wins),
  mutation-verified.
- Resolve context-carried agents before reporting their model: /model
  status now mirrors the run path's effective-agent rule — explicit
  message/thread choice overwrites every carrier, then the merged
  carriers' agent_name (new _select_carried_agent_name, sharing the
  model-name precedence via _select_carried_value), then the
  session-layer assistant_id that only setdefaults into context. A
  session with context.agent_name=coder now reports coder-model instead
  of server default, and assistant_id=writer alongside
  context.agent_name=coder reports coder-model instead of writer-model.
  Regressions: context-carried, context-beats-assistant_id, and
  thread-pin-beats-context status tests.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 09:45:17 +08:00

48 KiB
Raw Blame History

IM Channels System (app/channels/)

Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk, GitHub) to the DeerFlow agent via Gateway's LangGraph-compatible API.

WeChat _read_outbound_bytes reads cap+1 off-loop; overflow returns None before encryption/upload; caps <=0 are unlimited. Cursor writes drain before cancellation; token expiry drains cursor/auth clearing under _auth_lock.

Architecture: Channels use langgraph-sdk HTTP to reach Gateway; Gateway owns thread lifecycle. The SDK client and raw-httpx /goal calls send process-local auth plus _csrf_headers(); internal auth is not CSRF-exempt.

Components:

  • message_bus.py - Inbound queue → dispatcher; outbound uses a dispatch-start listener snapshot. Unsubscribe neither cancels nor drains captured callbacks.
  • store.py - JSON persistence for channel_name:chat_id[:topic_id] → thread_id. Guard every _data access with _lock; list_entries() snapshots under the lock and formats after release so concurrent writers cannot resize the dict during iteration.
  • manager.py - Core dispatcher: creates threads via client.threads.create(), routes commands including /goal (setting a goal persists it through Gateway and then routes the objective as a chat turn) and /agent (list is owner-scoped; use creates a fresh thread and persists the Custom Agent selection under both the channel restart key and Web's canonical routing metadata so existing checkpoint lineage never changes agent across IM/Web), keeps Slack/Discord on client.runs.wait(), uses client.runs.stream(["messages-tuple", "values"]) for Feishu/Telegram incremental outbound updates, serializes same-thread Feishu turns in-manager when the channel's ChannelRunPolicy.serialize_thread_runs=True so rapid follow-ups queue instead of tripping the runtime busy reply, and switches to client.runs.create() (fire-and-forget, returns once the run is pending) for channels whose ChannelRunPolicy.fire_and_forget=True so long autonomous runs do not hit the SDK default 300s httpx.ReadTimeout Streaming error frames use the same failure path as transport exceptions. Publish the final outbound before releasing the inbound dedupe key, so provider redelivery can retry without overtaking the terminal reply. Log the frame's error name and message at WARNING with the thread ID; keep the synthetic error generic so payload details do not change reply selection. What may be published from the stream is an allowlist, not a denylist (_accumulate_stream_text): only assistant message types — LangChain serializes AIMessage.type as "ai" and AIMessageChunk.type as "AIMessageChunk", plus the OpenAI-style "assistant" spelling for foreign runtimes — become displayable text. The previous rule rejected only payloads whose type contained "tool" and therefore published everything else, which leaked DeerFlow's hidden model context to every streaming IM channel: DynamicContextMiddleware injects recalled memory as a hidden HumanMessage (type == "human") and rewrites the user's own turn into a new HumanMessage, DurableContextMiddleware injects a hidden <durable_context_data> HumanMessage, and LangGraph fans those state writes out on the messages-tuple stream. Proved live on a Buzz relay, which published a <memory> fact block and, in another run, a verbatim echo of the user's own message as the assistant's reply. Matching is by prefix (ai / assistant), never substring, because ordinary words contain "ai" (chain, domain). The message type is resolved by _stream_payload_type, which handles both the model_dump() shape DeerFlow's own gateway emits and LangChain's to_json() constructor shape (whose top-level type is the literal "constructor", with the class name at the tail of the id path). A bare str payload is no longer accepted at all: it carries no type information, so it cannot be attributed to the assistant, and nothing in DeerFlow produces one (runtime/serialization.py::serialize_messages_tuple always emits [message_dict, metadata]).
  • base.py - Abstract Channel base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use _submit_threadsafe_coroutine() (or _submit_threadsafe_coroutine_future() to await the outcome through asyncio.shield): it creates and retains the real asyncio.Task on the owner loop instead of treating run_coroutine_threadsafe()'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and stop() must call _close_and_drain_threadsafe_futures() before tearing down SDK resources. Blocking SDK teardown in stop() (a client close(), a thread join()) must run off the event loop through asyncio.to_thread, since every run and channel on the Gateway shares that loop and stop() also serves POST /api/channels/{name}/restart; Slack's SocketModeClient.close() is a shielded, tracked task so a cancelled stop leaves it running and a retried stop awaits the same close. Anchored by tests/blocking_io/test_channel_stop_offloop.py. Proactive push (Channel.send_notification): the scheduled-task outbox pushes markdown to a bound identity with no inbound frame. The base default raises NotImplementedError (after a ChannelUnavailable check when not running). Scheduler/Settings read the proactive_notifications flag in capabilities.py; providers without it get no rows (legacy rows end terminal failed); test_channel_capabilities.py pins it to the registry and overrides. Adding push: implement it (return after the ACK), raise ChannelUnavailable for transient failures (parks, no retry used; rejections count), flip the flag. Only WeCom pushes today (WSClient.send_message); its SDK raises RuntimeError for a dead socket and a rejection alike, so only the Reply ack error prefix counts; other RuntimeErrors, ConnectionError/TimeoutError/OSError and the websockets close exception park. Before sending, the worker re-checks the binding (owner-scoped resolve_connections, before liveness): a gone target is mark_failed(terminal=True); a lookup failure parks. Terminal rows ignore later status writes. Scheduler side: _on_finalization enqueues each notice in the outcome's transaction, so recovery notifies once (unique key; idle ends use idle:); one message per occurrence (stop > pause > finish > run outcome); plain trials and interrupts stay silent; text (scheduler/notification_text.py) is localized, without IDs or links. Lifespan starts NotificationDeliveryWorker after the channel service (detaching enqueue if it cannot) and stops it first.
  • wecom.py - WeCom WebSocket channel with proactive send_notification; _ws_transport_up maps disconnects to ChannelUnavailable (no retry used). capabilities.py - SDK-free CHANNEL_CAPABILITIES.
  • service.py - Drain manager work before transports; retain timed-out instances for retry. Live Feishu/DingTalk/Telegram joins raise ChannelStopTimeout: retain, defer replacement/restart, log WARNING without traceback; wedged instances report not running. Reload returns snapshots; restart owns cached fallback/publication under the channel lock. _stopping fences starts/publication (including after pre-start I/O) until shutdown succeeds. Lock manager startup through stale cleanup; capture the shutdown generation before locking to fence queued starts. stop() stays unlocked to fence in-flight startup.
  • slack.py / feishu.py / telegram.py / discord.py / dingtalk.py - Platform-specific implementations (feishu.py tracks the running card message_id in memory and patches the same card in place; telegram.py accepts inbound text/photos/documents, preserves media captions, hands token-free attachment bytes to the shared upload pipeline, edits the "Working on it..." stream target in place via editMessageText, and can optionally send final Markdown replies as Rich Messages through channels.telegram.rich_messages — the Rich path only fires when rich_messages is on and the text contains a rich construct (fenced code, emphasis, a table, a task list, <details>, block math, or a link), so structured command/error replies (none of these) stay plain text and their newlines and <placeholder> tokens survive; discord.py runs _on_message on discord.py's private loop, so connection-repository work (identity lookup with the reservation commit, /connect binding) goes to the Gateway loop through _submit_threadsafe_coroutine — the SQLAlchemy engine is bound there — and bind replies return through _run_on_discord_loop; typing registers before that hand-off settles, and a failed hand-off skips the ack and stops the indicator it started unless another message still relies on it; discord.py registers typing loops before inbound handling yields and _start_typing() refuses work once _running is false; each tick sends the indicator with discord.py 2.x's await channel.typing() (trigger_typing() was removed in 2.0, and each typing loop logs its first failed tick at WARNING, later ones at DEBUG); because stop() runs on the main loop while typing tasks belong to _discord_loop, normal cross-thread cancellation, awaiting, and map cleanup are scheduled there with a bounded wait, while _run_client() drains tasks in its finally block before an exception/disconnect can make that loop unusable; an already-stopped foreign loop must never have its tasks awaited from the main loop; to that end every outbound cross-loop call (send / send_file / _get_channel_or_thread) goes through _run_on_discord_loop, which bounds the await (DISCORD_OUTBOUND_TIMEOUT_SECONDS 30s; DISCORD_UPLOAD_TIMEOUT_SECONDS 120s for file uploads, whose unbounded-size payload needs room for a slow uplink plus 429 retry-after) and fails fast with a RuntimeError when the client loop is missing or not running — a dead client becomes a logged send failure instead of a permanently hung ChannelManager worker — and is_running reports client-thread aliveness (like feishu.py) so ensure_channel_ready can restart the channel after _run_client() exits on a fatal error; dingtalk.py optionally uses AI Card streaming for in-place updates when card_template_id is configured, and overrides receive_file to download inbound images (picture/richText) and documents (file) by downloadCode into the thread uploads bucket, mirroring feishu.py)
  • buzz.py - Buzz (Nostr relay) implementation: one NIP-42-authenticated websocket, pubkey-allowlist + mention/DM/thread-follow gating (thread-follow reads via the manager's connection_identity.lookup_thread_id, so bound authors' repo-only threads count), streaming replies via in-place kind-40003 edits; requires the buzz dependency extra. Its durable seen-event replay guard uses async aseen() / arecord() / aflush() boundaries: initial JSON reads and coalesced atomic writes run off the Gateway event loop, and stop() awaits the final flush before returning. Subscription model (operator-facing version in IM_CHANNEL_CONNECTIONS.md): the relay fans kind-9 chat events out only to channel-scoped subscriptions, proved against a live relay — REQ {"kinds":[9]} is accepted and answered with EOSE but never receives an event (the connector authenticated and then sat silent forever), REQ {"kinds":[9],"#h":[uuid]} works, and a multi-value #h matches nothing, so it is strictly one REQ per channel (the same shape as Buzz's own buzz-acp harness). Every connection therefore rebuilds three kinds of subscription after NIP-42 auth: buzz-discovery ({"kinds":[39000]}, a historical query returning exactly the channels this identity belongs to, one stored event each then EOSE — adding #p returns zero, do not "narrow" it), buzz-membership ({"kinds":[44100,44101],"#p":[us],"since":<connect time − MEMBERSHIP_LOOKBACK_SECONDS>}, the relay-signed member-added/member-removed notifications whose p tag names the affected member and h tag the channel), and one buzz-chat-<uuid> per discovered channel. Chat subscriptions open as each kind-39000 arrives; the discovery EOSE is the completeness barrier that retries any that failed and warns when discovery found nothing. A kind-44100 for our pubkey subscribes to the new channel live (then re-issues discovery so its name/type reach the DM-detection cache, but only when that channel's metadata is actually missing — an unconditional refresh is how a burst of 44100s multiplied into one discovery pass each); a kind-44101 issues buzz_nostr.close_frame for exactly that channel's subscription and drops its metadata. The membership subscription is scoped to LIVE events and this is load-bearing: buzz-relay stores 44100/44101 and serves history newest-first (default limit 2000), so an unscoped filter replayed the whole membership history on every connect — every stored add read as live, re-running discovery once each (M+1 discovery passes × N stored kind-39000 events per connect, observed live as two channel discovery complete lines and one channel logged <unnamed> because the 44100 path subscribed before its metadata arrived), re-subscribing channels we have since been removed from, and letting a stored 44101 transiently unsubscribe a channel we are still in. since is anchored at the moment the socket opened (_session_started_at) minus MEMBERSHIP_LOOKBACK_SECONDS (60) of slack, which covers both relay clock skew and a membership change published during the connect/auth handshake; the slack can only cost an idempotent replay of the last minute. A relay CLOSED frame is recovered, not merely forgotten — every subscription on the socket fails silently when dropped, so _handle_closed re-issues it, bounded by MAX_RESUBSCRIBE_ATTEMPTS (3) per subscription id per connection and per auth epoch (_resubscribe_attempts is reset on session start, on re-auth, and by stop(), so pre-auth rejections — which the auth branch already recovers wholesale — never spend the authenticated session's budget). The first retry is immediate (the common case is a one-off hiccup); later ones back off 1s then 2s, awaited inline in the read loop rather than in a background task that could outlive its own socket. auth-required: before this socket has completed its NIP-42 handshake is the expected bootstrap sequence, not a refusal, and _handle_closed short-circuits it ahead of every recovery path: the connector opens its control REQs immediately in case the relay serves unauthenticated reads, a closed relay answers auth-required: plus an AUTH challenge, and the auth branch re-opens everything. That case is logged at DEBUG and consumes neither the permanent-refusal branch nor the retry budget (a chat subscription is still dropped from _chat_subscriptions, since it genuinely is not subscribed and discovery is what re-opens it). Treating it as permanent produced an operator-facing warning claiming discovery/membership tracking was DOWN in the same run where discovery then completed, every channel was subscribed, and a brand-new channel's kind-44100 was picked up one second later. The boundary is the per-socket _auth_completed flag, set once the signed AUTH event has been sent and cleared on session entry, session exit, and stop(); the same reason after that stays loud, and says the subscription is down until the relay's next AUTH challenge or the next reconnect rather than borrowing the non-auth wording. Then _is_transient_close decides whether to retry at all: NIP-01/NIP-42 auth-required:/restricted:/blocked:/mute:/invalid:/pow: prefixes and buzz-relay's own removal/revocation prose are permanent (do not fight the relay over a channel that is no longer ours; a post-auth auth-required: is recovered by the AUTH branch, not by re-issuing), rate-limited:/error:/no-reason-at-all and anything unrecognized are transient — the default resolves toward "keep listening" because going silently deaf is the failure this exists to remove, and the attempt budget bounds a wrong guess. A chat CLOSED is only ever recovered for a channel already in _chat_subscriptions: a CLOSED is relay-supplied, so acting on an unknown one would let a relay induce a subscription just by naming a channel. Every subscription that goes unlistened is logged at WARNING, never INFO. Subscription ids are deterministic per channel precisely so one can be replaced or closed without disturbing the others on the socket, and _chat_subscriptions is per-socket state cleared on session end, on re-auth (a pre-auth REQ may have been rejected), and by stop(). Three bounds on remote-fed state: MAX_CACHED_CHANNELS (512) caps the kind-39000 metadata cache, the watermark map, and the resubscribe-attempt map; MAX_CHANNEL_SUBSCRIPTIONS (256, well under buzz-relay's own 1024-per-connection ceiling) caps live chat subscriptions — at the cap new channels are refused and named in a warning rather than evicting a working subscription. Known bound (documented, not fixed): the relay caps historical delivery at 2000 events per subscription, newest-first, even with a since, so >2000 unread messages in a single channel across a disconnect loses the oldest — the relay never sends them and the watermark advances past them. That is the one remaining path that can skip; everything else fails toward replay. Trust model (operator-facing version in IM_CHANNEL_CONNECTIONS.md): every inbound EVENT is authenticated at the single handle_relay_frame choke point — the NIP-01 id is recomputed from the delivered payload and the BIP-340 Schnorr signature verified against the claimed pubkey (buzz_nostr.verify_event, pure and total: malformed input returns False, never raises) — so ev["pubkey"], the authorization principal for both the allowlist and the /connect bind, cannot be forged by a relay the DeerFlow operator does not run. What remains trusted is the authorship of kind-39000 channel metadata: any member can sign one, and because per-channel subscriptions are now driven by discovery, a forged kind-39000 has two effects rather than one — it can mark a channel type: "dm" (relaxing require_mention for that channel) and it can induce a chat subscription for a channel of the forger's choosing, since the channels we listen to are exactly the channels we hold metadata for. Neither makes anything be acted on: allowed_users and per-event signature verification are independent gates, so an induced subscription only means the relay reads its own traffic back to a subscriber that drops it, bounded by MAX_CHANNEL_SUBSCRIPTIONS (which refuses rather than evicts, so it cannot displace a real channel). Same for a forged kind-44100, except its p tag is re-checked locally so it must at least name us. Closing this needs a configured trusted relay pubkey, which relay_url is not. allowed_users is deny-by-default (empty = nobody, unlike siblings' empty = everyone), so start() logs a WARNING when it is empty and each drop logs at DEBUG. The resubscribe cursor (since) is per channel, advances only for events that were actually processed, and never past now + MAX_FUTURE_SKEW_SECONDS, because it is peer-supplied (created_at) and a single future-dated event otherwise made the connector permanently deaf. Per channel rather than global is the safety-critical half: subscriptions are per channel, so one shared cursor is the newest event seen in any channel and a busy channel would drag it past a quiet channel's unread messages, skipping them on the next reconnect — measured on a live relay, three channels of one identity sat ~28h apart. Per-channel cursors can only ever cost duplicate delivery (absorbed by the manager's event_id dedupe), and an evicted cursor degrades to "no since", i.e. the relay's default backlog — both fail toward replay, never toward a miss. Streaming tracks every oversize chunk index (_stream_targets for chunk 0, _stream_tails for the rest), since the manager republishes cumulative text and reposting chunks[1:] per update flooded the channel; all of it is per-connection state cleared by stop(), and the remote-fed kind-39000 cache is capped at MAX_CACHED_CHANNELS. send() refuses outright to publish text carrying a hidden model-context wrapper (<memory>, <durable_context_data>, <system-reminder> — _HIDDEN_CONTEXT_MARKERS), logging at ERROR and clearing the stream bookkeeping on a blocked is_final. This is defense in depth behind the manager's allowlist, and it lives here rather than in a sibling connector because on Buzz a leak is permanent: every streaming update is an immutable public Nostr event, so a corrective edit only changes what clients render while the original leaked event stays on the relay. Matching is on the literal opening tag, so a reply that merely talks about memory is still published Buzz seen-event shutdown stays bounded and retryable: aflush() awaits any in-flight write, attempts at most one final snapshot, leaves a still-changing generation dirty for fail-open replay, and returns without a live persistence timer. A Gateway cancellation does not cancel the underlying worker-thread write; BuzzChannel.stop() tracks cleanup completion separately from transport admission so ChannelService can retry the retained channel without racing a second snapshot against that write. The store is quiesced before stop (including the already-stopped guard), so a timed-out relay task that records after stop only marks data dirty and cannot schedule detached file work; a repeated stop() still drains that dirty state, while BuzzChannel.start() explicitly resumes scheduling and flushes it automatically.
  • github.py - Webhook-driven GitHub channel. Inbound messages come from POST /api/webhooks/github; outbound is log-only because GitHub agents post explicitly with gh from their sandbox when they choose to comment or create a PR
  • app/gateway/routers/channel_connections.py - Browser-facing user connection and disconnect APIs
  • deerflow.persistence.channel_connections - SQL-backed user-owned connection, optional credential, connect state, and conversation store

Message Flow:

  1. External platform -> Channel impl -> MessageBus.publish_inbound()
    • For GitHub, the webhook router verifies the delivery then calls fanout_event(bus, ...); matching agent bindings publish one InboundMessage each instead of a long-polling channel worker.
    • Telegram photo/document updates use the largest photo size or document metadata, preserve message.caption, enforce the hosted Bot API's 20,000,000-byte download ceiling before and after download, and never expose the token-bearing Bot API file URL. Downloaded bytes cross the adapter/manager boundary only through message_bus.INBOUND_FILE_CONTENT_KEY; the manager consumes that transient field before persisting safe upload metadata.
    • Feishu/Lark inbound image/file downloads read at most 20,000,001 bytes and reject anything above 20,000,000 bytes before persistence or sandbox sync. Oversize, unsafe-path, and path-resolution failures rewrite only that attachment placeholder to Failed to obtain the [type] so later attachments in the same message can still load.
    • WeChat iLink inbound image/file downloads stream with an in-flight cap derived from channels.wechat.max_inbound_image_bytes / max_inbound_file_bytes (20 MB / 50 MB defaults) that aborts before the full read, and the payload-supplied media.full_url must pass a scheme + dot-boundary host-suffix allowlist (channels.wechat.allowed_media_hosts, defaulting to the qq.com family plus the configured cdn_base_url host) before anything is fetched. The limits are PLAINTEXT limits while the stream measures CIPHERTEXT, so the in-flight cap is the PKCS#7-padded size of exactly-limit plaintext (WechatChannel._stream_cap_for); the exact post-decryption check stays as the authority. There is deliberately no URL re-fetch fallback: _read_wechat_inbound_file (manager) only reads the locally staged path — the channel always stages one before publishing — so nothing fetches a WeChat URL outside the channel-side gate. WeCom media URLs (frame-supplied url, no per-channel size config) get the same style of host gate — qq.com suffixes plus the COS shape ww-aibot-img-<APPID>.cos.<region>.myqcloud.com WeCom serves signed links from, where the numeric suffix must be one of the verified WeCom-owned Tencent Cloud APPIDs (_WECOM_MEDIA_COS_APPIDS, currently only the 1258476243 observed in Tencent's published callback examples) because bucket names are user-chosen and any Tencent Cloud account can register a lookalike ww-aibot-img-* bucket; other accounts go through operator suffixes from channels.wecom.allowed_media_hosts resolved from the live channel at read time — and the manager-level MAX_INBOUND_URL_FILE_BYTES (50 MB) streaming cap. Both URL downloads and _download_cdn_bytes request Accept-Encoding: identity, refuse a response with any residual Content-Encoding before reading, and iterate aiter_raw(): httpx's transparent decoding would allocate the full decompressed body before the byte cap sees a chunk. No URL-based inbound-media log line may contain any part of the URL (signed links carry credentials in path and query): failure labels use the attachment filename or host only (manager._inbound_file_label), and reader failures log a sanitized exception summary — class name plus HTTP status (manager._reader_error_summary, wechat._media_download_error_summary), never the URL-bearing message or traceback. The rule extends to the HTTP client libraries' own records: httpx emits HTTP Request: GET <full URL> at INFO via the bare httpx logger, and urllib3 — enumerated against the installed source; every remaining emitter logs host:port only (connection establishment/reset) or one absolute URL (the header-parse warning) that the generic pattern rewrites — renders a request target in five shapes, each needing a dedicated redaction pattern because the generic absolute-URL pass cannot see a target with no scheme: Redirecting <target> -> <target> at INFO via urllib3.poolmanager and DEBUG via urllib3.connectionpool (either slot may be an origin-form relative Location, which pool/connection callers pass or the Location header carries; the shape keeps the ^Redirecting prefix anchor because -> /path arrows also appear in non-URL logs — sandbox mount mappings — and must pass through untouched, while both slots consume through end-of-line: the raw Location header may carry interior spaces, and the recursive urlopen frame can pass it on as the next request target. A slot is kept verbatim only when the generic pass consumes it whole (logging_config._url_pass_consumes_slot asks that pass itself, so the kept set cannot drift from its host/rest stop conditions) — every other RFC 3986 relative-reference form (slash-less paths, query-only, fragments, network-path), non-hierarchical scheme, space-carrying slot, quote-at-boundary slot (https://h/a')b?sig=…, where rest stops at the closing mark), and empty-host absolute (https:///path?sig=…, which host never matches) collapses, so neither download?sign=… nor other page?sig=… leaks its signed query), the per-request scheme://host:port "METHOD /path?query HTTP/x.x" DEBUG line, Retry: <target> DEBUG and Retrying (…) after connection broken by '<error>': <target> WARNING — above the INFO root, so reachable at the default log level — via urllib3.connectionpool, and Incremented Retry for (url='<target>') DEBUG via urllib3.util.retry (origin-form on the request path, absolute on the redirect path). The generic pass's rest treats a quote as a closing mark only at a boundary — followed by whitespace, a closing parenthesis, or end of string — so urllib3's Incremented Retry for (url='…') keeps its ') scaffolding while a quote embedded in a URL stays consumed and redacted. Logger filters only see records emitted through that exact logger — child loggers neither inherit them nor trigger them on propagation — so UrlRedactionFilter is attached to the httpx logger AND to every root handler by configure_logging (handler-level filters see propagated records), which also keeps Telegram's token-bearing Bot API paths (api.telegram.org/bot<token>/..., emitted by python-telegram-bot's httpx client) out of the logs.
  2. ChannelManager._dispatch_loop() consumes from queue
  3. For user-owned channel connections, incoming messages carry connection_id, owner_user_id, and workspace_id; owner_user_id becomes the DeerFlow run user_id, while the raw platform user id remains channel_user_id. The Gateway accepts channel_user_id only from an internally authenticated channel caller's top-level body.context, clears it from both free-form body.config sections, and writes it into runtime context only (never configurable, which is checkpointed). bash_tool exposes it to sandbox commands as the fixed env var DEERFLOW_CHANNEL_USER_ID — via a shell-quoted command-string prefix, NOT the execute_command(env=...) channel, which is reserved for request-scoped secrets and would switch AioSandbox onto the bash.exec path (image >= 1.9.3, fresh session per call). Per-call injection keeps group-chat identity correct (one thread/sandbox, many senders) without depending on the AIO shell's session semantics: every IM-channel command carries an explicit export VAR=<id>; (valid id) or unset VAR; (empty / non-str / over the 256-char cap). The AIO no-env path reuses a persistent shell session (the reason for the class lock, #1433), so a bare command could otherwise resolve a stale id an earlier sender exported; the unset closes the window the length/type guard would open (a dropped id would inherit the previous sender's value). Non-IM runs (no channel_user_id in context) are left untouched. A second, always-present prefix carries the authenticated DeerFlow user id (#3919) as the fixed env var DEERFLOW_USER_ID, emitted outermost of the two (export DEERFLOW_USER_ID=<id>; export DEERFLOW_CHANNEL_USER_ID=<id>; cd ...), so a reader sees which user the command runs as before which IM sender it came from; unlike the channel id it is never skipped for a non-IM run, because resolve_runtime_user_id falls back to default (same export/unset discipline and 256-char cap). Neither prefix is emitted on the Windows local sandbox (its PowerShell/cmd.exe fallback has no export/unset): there the user id is published through execute_command(env=...) instead — LocalSandbox layers env into the per-process environment and, unlike AioSandbox, has no persistent session to keep honest — while the channel id is simply absent. Propagates across task delegation: task_tool captures the dispatching turn's id and the subagent executor forwards it into the subagent's runtime context, same as the guardrail attribution fields. The runtime-context value is authorization-grade at the Gateway/guardrail boundary, but both exported shell variables (DEERFLOW_CHANNEL_USER_ID, DEERFLOW_USER_ID) remain informational because any bash command can overwrite its own environment, and anything the command sources or launches (a dependency, a sourced rc file on the host-bash path) can silently re-export a different id before a skill reads it — only the outer prefix on the next bash_tool call re-asserts the true value; skills must not treat the shell variable itself as authenticated identity. Tests: tests/test_gateway_services.py, tests/test_channel_user_id_env.py
  4. For chat: look up/create thread through Gateway's LangGraph-compatible API
    • First-use creation is serialized by a waiter-aware keyed lock over (channel_name, chat_id, topic_id). Holders and queued callers share one lock generation through failure and cancellation; idle entries are reclaimed only after the final participant leaves. This prevents a late inbound message from bypassing a queued creator and splitting one conversation across duplicate Gateway threads.
  5. Feishu/Telegram chat: runs.stream() → accumulate AI text → publish multiple outbound updates (is_final=False) → publish final outbound (is_final=True)
  6. Slack/Discord chat: runs.wait() → extract final response → publish outbound 6b. GitHub chat (ChannelRunPolicy.fire_and_forget=True): runs.create() returns once the run is pending; the manager does not wait for the final state and does not publish an outbound; the agent posts its own reply via sandbox gh. ConflictError on a busy thread still trips the standard THREAD_BUSY_MESSAGE path (log-only on GitHub); with buffer_followups_on_busy=True (GitHub's default) the triggering message is captured into a per-thread buffer so a concurrent comment is not silently dropped (see "Follow-up buffering while busy").
  7. Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets config.update_multi=true for Feishu's patch API requirement). Messages already sent inside an existing Feishu topic carry a compact source-message preview in that card, and queued same-thread follow-ups patch their own source message's card from queued → running → final without falling back to the generic busy reply.
  8. Telegram streaming: the "Working on it..." placeholder message is registered as the stream target; non-final updates editMessageText it in place (channel-side throttle: 1s in private chats, 3s in groups due to Telegram's 20 msg/min group cap; 4096-char truncation; rate-limited updates dropped); the final update performs the last edit and splits >4096 texts into follow-up messages
  9. DingTalk AI Card mode (when card_template_id configured): runs.stream() → create card with initial text → stream updates via PUT /v1.0/card/streaming → finalize on is_final=True. Falls back to sampleMarkdown if card creation or streaming fails
  10. For commands (/new, /status, /models, /model, /memory, /goal, /agent, /help): handle locally or query Gateway API. /agent list reads the effective owner's Custom Agents; /agent use <name> validates in the same owner bucket, creates a new thread, and persists channel_agent_name in its metadata. A Custom Agent writes canonical metadata.agent_name so thread-search routes Web continuation to /workspace/agents/<name>/chats/<thread_id>; lead_agent omits it and stays on the ordinary chat route. /model <name> persists channel_model_name in the current thread's metadata (in place, no fresh thread); /model reports the effective model, /model default clears it (stale pins self-clear). The channel_agent_name cache reloads after restart before a resumed turn routes. An explicit selection also normalizes agent_name across the top-level run context and both RunnableConfig carriers (context, configurable) — cleared for lead_agent — before Gateway's setdefault merge, so stale channel defaults cannot silently win.
  11. Outbound → channel callbacks → platform reply
    • GitHub is the exception: the channel logs the final assistant message and does not auto-post it to GitHub. Agents use the sandbox gh CLI (gh issue comment, gh pr comment, gh pr create, etc.) for intentional writeback, so silence is cheap when several agents fan out on the same event.

Owner-scoped file storage: inbound files, uploads, and output artifacts are staged under the DeerFlow owner's bucket so they land where the agent run reads/writes (users/{user_id}/threads/{thread_id}/user-data/{uploads,outputs}). ChannelManager._handle_chat resolves the storage owner once via _channel_storage_user_id(msg) (sanitized owner id, falling back to safe(msg.user_id) for unbound auth-enabled channels — mirroring _resolve_run_params's run identity; None only when no identity is available) and threads it as the user_id= kwarg through the file pipeline:

  • Channel.receive_file(msg, thread_id, user_id=...) — owner-bound channels persist downloaded files under the owner's bucket instead of the default bucket
  • FeishuChannel._receive_single_file(...) / DingTalkChannel._receive_single_file(...) — normalize filenames and claim/write through write_upload_file_no_symlink(..., exclusive=True), retrying FileExistsError under the channel lock; the final basename drives agent-visible paths and sandbox sync
  • sandbox_files.py — non-mounted Feishu/DingTalk syncs acquire unique execution holders, drain blocking update_file workers across repeated cancellation, and release only after the last sandbox operation, so a parallel run cannot close the shared client mid-upload. Feishu requests provider release after the final holder exits, parking standalone temporary sandboxes; DingTalk retains its non-releasing holder behavior.
  • _ingest_inbound_files(...) and the underlying ensure_uploads_dir / get_uploads_dir — owner-scoped via the same kwarg
  • _resolve_attachments / _prepare_artifact_delivery — resolve output artifacts from the bound owner's bucket through app.gateway.path_utils.resolve_outputs_confined_path, the same outputs-only rule the artifact editor uses, so a sibling uploads//workspace/ path or a symlink planted in outputs/ is skipped with a warning The cached value is reused for both the blocking (runs.wait) and streaming (_handle_streaming_chat) paths, so uploads and artifact delivery always target the same bucket even if a channel returns a rewritten InboundMessage from receive_file. The bucket id matches the memory bucket resolved by _resolve_memory_user_id (both normalize through make_safe_user_id).

Configuration (config.yaml -> channels):

  • langgraph_url - LangGraph-compatible Gateway API base URL (default: http://localhost:8001/api)
  • gateway_url - Gateway API URL for auxiliary commands (default: http://localhost:8001)
  • In Docker Compose, IM channels run inside the gateway container, so localhost points back to that container. Use http://gateway:8001/api for langgraph_url and http://gateway:8001 for gateway_url, or set DEER_FLOW_CHANNELS_LANGGRAPH_URL / DEER_FLOW_CHANNELS_GATEWAY_URL.
  • Per-channel configs: feishu (app_id, app_secret), slack (bot_token, app_token), telegram (bot_token, optional rich_messages for final Markdown Rich Messages; allowed_users takes numeric IDs or one ID, warns on unparseable entries like @names, and denies everyone if none parse), dingtalk (client_id, client_secret, optional card_template_id for AI Card streaming), github (operator kill-switch enabled, plus default_mention_login for mention-required GitHub triggers), buzz (relay_url, private_key)

User-owned channel connections (config.yaml -> channel_connections):

  • Disabled by default. It is a user-binding layer on top of the existing channels.* runtime config, not a replacement for provider bot credentials.
  • No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
  • Telegram binds via deep-link /start <code> over long polling; Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use /connect <code> through their outbound workers.
  • WeChat polling_timeout, polling_retry_delay, qrcode_poll_interval, and qrcode_poll_timeout require positive finite seconds; invalid values use defaults to prevent hot loops or endless sleeps.
  • allowed_users.py uses WeChat/Telegram ID coercers: unset/null/empty collections/blank strings allow all; invalid entries warn; all-invalid input denies all with an error. WeChat stores integer-valued numbers as integer text, warns without splitting scalar separators, and gates users before media downloads.
  • WeCom serializes start() and stop() for each channel instance. The SDK connect() task covers connection setup only; after the handshake, the SDK owns a separate receive task. Shutdown cancels an in-progress connection attempt and awaits the SDK's actual asynchronous receive-task/socket cleanup before releasing lifecycle state or allowing a restart. Cancellation of stop() still propagates, but only after owned cleanup finishes and lifecycle references are cleared; real connection failures remain reported by _on_ws_task_done.
  • WeCom outbound content is capped at the protocol's 20480 UTF-8 bytes: stream replies clip on a character boundary with a truncation marker (one stream carries the whole reply and cannot split mid-way), while proactive pushes split into at most 10 sequential markdown messages per push, with the remaining tail clipped and marker-terminated. Both paths measure bytes, not characters. A per-chat send lock serializes each split batch end to end, because manager workers run concurrently and two long pushes to the same chat would otherwise interleave chunks; locks are reference-counted and reclaimed once no sender holds or waits on them, so the registry does not grow for the life of the Gateway.
  • Frontend APIs: GET /api/channels/providers, GET /api/channels/connections, POST /api/channels/{provider}/connect, and DELETE /api/channels/connections/{connection_id}.
  • Browser APIs remain protected by normal Gateway auth/CSRF. Provider messages arrive through the already-configured channel workers.
  • Runtime credential configure/disconnect treats the runtime-config file write and app.state.channels_config reconciliation as one cancellation-owned commit; do not let caller cancellation escape between them.
  • Provider-level connection_status reflects the user's newest connection row. With no binding it is not_connected, except in auth-disabled local mode where a configured running channel reports connected because all channel messages already route to the default user.
  • Slack replies use the configured operator bot token from channels.slack unless per-connection credentials are present; unreadable or corrupt stored credentials are treated as unavailable.
  • Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom workers resolve incoming platform identities to connection records before reaching ChannelManager.
  • Connect-code ordering: /connect <code> (Telegram /start <code>) precedes allowed_users, so even non-allowlisted senders can bind with a valid code. The allowlist gates ordinary messages. Codes rely on secrecy: secrets.token_urlsafe(16), 600 s TTL, one-time consume_oauth_state, shown only in the initiating browser, never echoed to chat.
  • Single-active-owner transfer semantics: an external identity is keyed by (provider, external_account_id, workspace_id). The latest successful bind wins — upsert_connection revokes other owners' active rows for the same identity (ownership transfer). This invariant is enforced at the DB layer by the partial unique index uq_channel_connection_active_identity (WHERE status != 'revoked'), so concurrent connects from different owners cannot both end connected; the losing writer retries against the now-visible state. find_connection_by_external_identity therefore resolves deterministically.
  • See backend/docs/IM_CHANNEL_CONNECTIONS.md for provider setup, operational notes, and the architecture diagrams (connect-code flow, single-active-owner transfer, sync vs streaming dispatch, owner-scoped file storage pipeline).

GitHub event-driven agents (webhook-driven IM channel):

  • Custom agents declare a github: block in their config.yaml to bind to repos and event triggers; the webhook route is fail-closed by default (mounted only when GITHUB_WEBHOOK_SECRET is set) and exempt from auth/CSRF because authenticity is enforced by HMAC.
  • Registry caching is keyed by the configured agent store's opaque signature: file storage uses agent config mtimes, while database storage hashes the ordered owner/name/config/soul contents so same-timestamp writes still invalidate webhook routing.
  • Outbound is log-only by design: each agent posts its own reply mid-run via the gh CLI from its sandbox, so the manager uses fire_and_forget=True and runs.create() returns once pending.
  • Follow-up buffering while busy (issue #4121): because outbound is log-only, the pre-existing THREAD_BUSY_MESSAGE reply on a ConflictError was invisible to the commenter — a comment posted while a run was already active looked like it had been silently ignored. When ChannelRunPolicy.buffer_followups_on_busy=True (GitHub's default), a ConflictError on runs.create() now also appends the triggering message to a per-thread, in-memory buffer (ChannelManager._followup_buffers) — deduped by GitHub delivery id, capped at FOLLOWUP_BUFFER_MAX_PER_THREAD (20, oldest dropped with a WARNING log on overflow). The first successful runs.create() on a thread now captures its run_id and spawns a background watcher that subscribes to that run's StreamBridge stream; once it observes END_SENTINEL, the watcher drains up to FOLLOWUP_DRAIN_BATCH_SIZE (10) buffered entries into one <followups-while-busy>-wrapped input and fires a follow-up runs.create() — itself watched the same way, so a backlog deeper than one batch chains into further drain cycles instead of growing one unbounded input. If that follow-up runs.create() itself hits ConflictError (e.g. a manual Web UI turn or a scheduled run raced onto the same thread), the batch is requeued rather than lost, and is retried whenever this manager next successfully creates and watches a run on that thread. Reactions/acknowledgment (e.g. GitHub's eyes/confused reaction API) on buffered comments are intentionally out of scope for this mechanism and left to a follow-up — comments are coalesced silently. Plumbing: the watcher needs the Gateway's StreamBridge singleton, which ChannelManager did not previously have access to; it is threaded from app.py's lifespan (where app.state.stream_bridge is already set by langgraph_runtime) through start_channel_service(get_stream_bridge=...) → ChannelService.__init__ → ChannelManager.__init__, as a zero-arg closure mirroring the existing launch_run=lambda **kwargs: launch_scheduled_thread_run(app=app, **kwargs) pattern used for ScheduledTaskService in the same lifespan function. A ChannelManager constructed without it (e.g. directly in a test) still buffers safely — it just has no watcher to auto-drain. Scope limitation: the buffer and watcher state are per-process, in-memory. Under GATEWAY_WORKERS>1 or multi-pod, a follow-up comment routed to a different worker process than the one running the busy thread's agent will not see that buffer. This is a known, deliberately deferred limitation with the same shape as the cross-pod gap described for issue #4120 (a shared buffer store or IM-leader election would be needed to close it) — single-process/single-pod deployments, the safe default, see no correctness issue from this, only the documented per-process scope.
  • See backend/docs/GITHUB_AGENTS.md for the architecture diagrams: webhook → fan-out → InboundMessage dispatch, preferred_thread_id = UUID5(repo, number, agent_name) thread determinism, mention-handle precedence chain, GH token lifecycle via GH_TOKEN/GITHUB_TOKEN per-call extra_env, and the narrow ConflictError (HTTP 409) thread-create race recovery.

QQ WebSocket channel

qq.py handles text C2C/group @mentions via httpx/websockets and the shared non-streaming manager. Keep service, provider, credential and config registration aligned. Binding scopes: <app_id>:c2c or <app_id>:group:<group_openid>; topics retain the sender. Bind commands precede allowlists; reserve intake before identity lookup.

Start waits for READY; stop drains tasks/HTTP. Keep reads/heartbeats independent of intake and SSL/client setup off-loop. Accept only TLS/443 gateways at api.bot.qq.com or the sgroup.qq.com family; reject URL credentials/redirects. Logs exclude secrets, content, provider bodies and raw exceptions.

thread_ts is the source message ID. Lock each source; preserve expiry, sequence and quota (C2C: four/60 min; group: five/5 min) across redelivery. Ignore progress; reuse sequences only after definite auth failure, never ambiguous I/O. Unknown timestamps use a full advisory window with content-free, rate-limited warnings. Evict expired contexts first; caches are bounded/process-local. One worker per bot.

Tests: test_qq_{channel,lifecycle,binding}.py, connection router and blocking-I/O. Setup: IM_CHANNEL_CONNECTIONS.md.