@@ -144,6 +144,51 @@ pub trait Channel: Send + Sync {
144144 false
145145 }
146146
147+ /// Self-loop guard for multi-agent runs.
148+ ///
149+ /// Returns the bot's own handle/identity on this channel
150+ /// (e.g. `@my_bot` for Telegram, the bot's user ID for Discord)
151+ /// when known, so the orchestrator can drop inbound events whose
152+ /// `sender` matches: a bot must never respond to its own
153+ /// messages, even if a misconfigured peer group lists the bot's
154+ /// handle as an external peer.
155+ ///
156+ /// **Channels that handle inbound traffic must override this.**
157+ /// The default `None` makes both layers of the orchestrator's
158+ /// self-loop guard (the SDK-side `drop_self_messages` here, and
159+ /// the agent-loop fallback `peers::should_drop_self_loop`) into
160+ /// no-ops — both layers consult the same `self_handle`, so a
161+ /// channel that returns `None` has no protection from looping on
162+ /// its own outbound. Outbound-only channels (webhook, gmail-push,
163+ /// voice-call) never see inbound and can keep the default. The
164+ /// in-tree overrides currently cover Telegram (`bot_username`
165+ /// cache), IRC (configured nickname), Discord (decoded from token),
166+ /// Slack (cached `auth.test` user_id); other inbound channels
167+ /// remain on the default and rely on per-impl filtering instead
168+ /// of the shared guard.
169+ fn self_handle ( & self ) -> Option < String > {
170+ None
171+ }
172+
173+ /// Whether the orchestrator should drop an inbound message as
174+ /// self-authored (multi-agent self-loop guard).
175+ ///
176+ /// Default implementation compares `msg.sender` against
177+ /// [`Self::self_handle`] case-insensitively, after stripping a
178+ /// leading `@` from each side so Telegram-style handles match
179+ /// regardless of which form the SDK delivers. Override only for
180+ /// platforms whose identity comparison is non-string (e.g. a
181+ /// numeric Discord user ID is `as_str` already; this default
182+ /// works there too).
183+ fn drop_self_messages ( & self , msg : & ChannelMessage ) -> bool {
184+ let Some ( handle) = self . self_handle ( ) else {
185+ return false ;
186+ } ;
187+ let handle_norm = handle. trim_start_matches ( '@' ) . to_ascii_lowercase ( ) ;
188+ let sender_norm = msg. sender . trim_start_matches ( '@' ) . to_ascii_lowercase ( ) ;
189+ !handle_norm. is_empty ( ) && handle_norm == sender_norm
190+ }
191+
147192 /// Whether this channel supports multi-message streaming delivery.
148193 fn supports_multi_message_streaming ( & self ) -> bool {
149194 false
@@ -285,3 +330,87 @@ pub trait Channel: Send + Sync {
285330 true
286331 }
287332}
333+
334+ #[ cfg( test) ]
335+ mod tests {
336+ use super :: * ;
337+
338+ /// Stub channel that overrides `self_handle` so the default
339+ /// `drop_self_messages` implementation can be exercised.
340+ struct StubChannel {
341+ handle : Option < String > ,
342+ }
343+
344+ #[ async_trait]
345+ impl Channel for StubChannel {
346+ fn name ( & self ) -> & str {
347+ "stub"
348+ }
349+ async fn send ( & self , _message : & SendMessage ) -> anyhow:: Result < ( ) > {
350+ Ok ( ( ) )
351+ }
352+ async fn listen (
353+ & self ,
354+ _tx : tokio:: sync:: mpsc:: Sender < ChannelMessage > ,
355+ ) -> anyhow:: Result < ( ) > {
356+ Ok ( ( ) )
357+ }
358+ fn self_handle ( & self ) -> Option < String > {
359+ self . handle . clone ( )
360+ }
361+ }
362+
363+ fn msg_from ( sender : & str ) -> ChannelMessage {
364+ ChannelMessage {
365+ id : "1" . into ( ) ,
366+ sender : sender. into ( ) ,
367+ reply_target : String :: new ( ) ,
368+ content : "hi" . into ( ) ,
369+ channel : "stub" . into ( ) ,
370+ timestamp : 0 ,
371+ thread_ts : None ,
372+ interruption_scope_id : None ,
373+ attachments : Vec :: new ( ) ,
374+ }
375+ }
376+
377+ #[ test]
378+ fn drop_self_messages_default_returns_false_when_handle_unknown ( ) {
379+ let channel = StubChannel { handle : None } ;
380+ assert ! ( !channel. drop_self_messages( & msg_from( "@anyone" ) ) ) ;
381+ }
382+
383+ #[ test]
384+ fn drop_self_messages_matches_exact_handle ( ) {
385+ let channel = StubChannel {
386+ handle : Some ( "@my_bot" . into ( ) ) ,
387+ } ;
388+ assert ! ( channel. drop_self_messages( & msg_from( "@my_bot" ) ) ) ;
389+ assert ! ( !channel. drop_self_messages( & msg_from( "@other_bot" ) ) ) ;
390+ }
391+
392+ #[ test]
393+ fn drop_self_messages_normalizes_at_prefix_and_case ( ) {
394+ let channel = StubChannel {
395+ handle : Some ( "My_Bot" . into ( ) ) ,
396+ } ;
397+ // SDK delivered with @ prefix, handle stored without. Match.
398+ assert ! ( channel. drop_self_messages( & msg_from( "@my_bot" ) ) ) ;
399+ // Both with @, mixed case. Match.
400+ let channel = StubChannel {
401+ handle : Some ( "@My_Bot" . into ( ) ) ,
402+ } ;
403+ assert ! ( channel. drop_self_messages( & msg_from( "@MY_BOT" ) ) ) ;
404+ }
405+
406+ #[ test]
407+ fn drop_self_messages_does_not_match_empty_handle ( ) {
408+ // A handle of "@" (effectively empty after normalization) must
409+ // not match every inbound message; the guard only fires when
410+ // the bot has a real handle to compare against.
411+ let channel = StubChannel {
412+ handle : Some ( "@" . into ( ) ) ,
413+ } ;
414+ assert ! ( !channel. drop_self_messages( & msg_from( "@anyone" ) ) ) ;
415+ }
416+ }
0 commit comments