Skip to content

feat(channels): mirror outbound channel_send into the inbound-routing session for cross-agent context #4824

Description

@neo-wanderer

Summary

When an agent calls the native channel_send tool, the outbound message currently has no path back into the session that will receive the user's reply. If Agent A sends a message into a channel and the user replies, the reply lands in the session of whichever agent owns the inbound channel binding (Agent B), not in Agent A's session. Agent B has no record of what Agent A pushed, so it cannot respond with context.

Current behavior

  • tool_channel_send (crates/librefang-runtime/src/tool_runner.rs:4745) executes the send and returns a ToolResult containing only an acknowledgement string (e.g. "Message sent to {recipient} via {channel}"). The message body itself is not persisted anywhere in any agent's session history.
  • Inbound channel messages are routed by SessionId::for_channel(agent_id, "channel:chat_id") (crates/librefang-kernel/src/kernel/messaging.rs:1764), where agent_id is the agent bound to the channel — not the agent that called channel_send.
  • Per-adapter self-echo filters (Messenger, Feishu, etc.) drop reflections of the bot's own send by matching the bot's platform user ID, so the outbound never re-enters as an inbound message.

Net effect: in any deployment with more than one agent (e.g. one cron/automation agent plus one chat-handling agent on the same channel), outbound sends from non-channel-owner agents become invisible to the agent that will eventually process the user's reply.

Proposed change

When channel_send is invoked, in addition to recording the tool result on the calling agent, write a synthetic turn into the inbound-routing session — i.e. SessionId::for_channel(channel_owner_agent_id, "channel:chat_id") — containing the actual outbound message body and an attribution to the originating agent.

Sketch:

  1. In tool_channel_send, after a successful send, resolve the channel-owning agent for (channel, chat_id) using the existing inbound routing logic.
  2. If that agent differs from the caller (or always, for consistency), append a message to the channel-owning agent's session for that chat. A system role with structured content seems safest — e.g. { from_agent_id, channel, chat_id, body, sent_at } — to avoid colliding with strict alternating-role drivers.
  3. No new response is triggered on the receiving session; the mirrored turn is context only. The channel-owner agent reacts on the next genuine inbound user message, which now has full context.

Why this approach

  • Preserves the current "one session per (channel-owner, channel, chat_id)" invariant — no addressing scheme changes for callers.
  • Surgical: localized to tool_channel_send and a small kernel helper to resolve the channel-binding owner.
  • Backwards-compatible: callers that don't care still get the existing tool result on their own session; channel-owner agents simply gain context they didn't have before.
  • Composable with future enhancements (e.g. an optional on_behalf_of_session parameter for explicit routing, or correlation tokens for platform-native reply threading) without conflicting with them.

Alternatives considered

  • Caller specifies target session (on_behalf_of_session: Uuid): more explicit but requires every caller — including cron-scheduled agents with no session reference — to know the routing key.
  • Per-(channel, chat_id) shared transcript: cleanest model conceptually (one timeline, multiple agent speakers), but a much larger refactor of session loading and history trimming.
  • Correlation tokens + platform reply primitives: useful when a chat hosts multiple parallel threads, but only meaningful on platforms with reply-to support (Telegram, Slack threads, Discord); does not help on flat channels.

The proposal above is intended as the smallest change that closes the gap; the alternatives can be layered on later if needed.

Open questions

  1. Should the mirrored turn be written unconditionally, or only when caller_agent_id != channel_owner_agent_id? Unconditional is simpler and lets the calling agent see its own outbound as context too if it ever processes inbound on the same channel.
  2. Role choice: system, assistant, or a new content-block kind? system avoids alternating-role driver issues but may be surfaced oddly in dashboards.
  3. Should the mirror also fire when channel_send fails partway (delivered to platform but ack lost)? Probably yes — the user may still see and reply to the message.
  4. Does the existing per-adapter self-echo filter need to be aware of mirrored turns to avoid double-counting if a platform later does reflect the outbound back as inbound?

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/channelsMessaging channel adaptersarea/kernelCore kernel (scheduling, RBAC, workflows)enhancementNew feature or requesthas-prA pull request has been linked to this issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions