Skip to main content

nexo_core/agent/
runtime.rs

1use super::admin_rpc::domains::processing::ProcessingControlStore;
2use super::agent::Agent;
3use super::agent_events::AgentEventEmitter;
4use super::behavior::{AgentBehavior, AgentTurnControl};
5use super::context::AgentContext;
6use super::effective::EffectiveBindingPolicy;
7use super::peer_directory::PeerDirectory;
8use super::routing::{route_topic, AgentMessage, AgentPayload, AgentRouter};
9use super::sender_rate_limit::SenderRateLimiter;
10use super::types::{InboundMedia, InboundMessage, MessagePriority, RunTrigger};
11use crate::heartbeat::{heartbeat_interval, heartbeat_topic, publish_heartbeat};
12use crate::runtime_snapshot::RuntimeSnapshot;
13use crate::session::SessionManager;
14use crate::telemetry::{inc_messages_processed_total, inc_proactive_event};
15use arc_swap::ArcSwap;
16use dashmap::DashMap;
17use nexo_broker::{AnyBroker, BrokerHandle};
18use nexo_config::types::agents::InboundBinding;
19use nexo_driver_loop::proactive::{build_tick_prompt, ScheduledWake};
20use nexo_memory::LongTermMemory;
21use nexo_tool_meta::admin::agent_events::AgentEventKind;
22use nexo_tool_meta::admin::processing::{PendingInbound, ProcessingControlState, ProcessingScope};
23use nexo_tool_meta::InboundMessageMeta;
24use serde_json::Value;
25use std::collections::VecDeque;
26use std::mem;
27use std::sync::Arc;
28use std::time::Duration;
29use tokio::sync::{mpsc, Mutex};
30use tokio::task::JoinSet;
31use tokio::time::{sleep_until, Instant};
32use tokio_util::sync::CancellationToken;
33use tracing::Instrument;
34use uuid::Uuid;
35pub struct AgentRuntime {
36    agent: Arc<Agent>,
37    broker: AnyBroker,
38    sessions: Arc<SessionManager>,
39    memory: Option<Arc<LongTermMemory>>,
40    peers: Option<Arc<PeerDirectory>>,
41    router: Arc<AgentRouter>,
42    // session_id → sender into that session's debounce task
43    session_txs: Arc<DashMap<Uuid, mpsc::Sender<InboundMessage>>>,
44    debounce_ms: Duration,
45    queue_cap: usize,
46    /// Per-binding sender rate limiters, keyed by
47    /// `EffectiveBindingPolicy::binding_index`. Built lazily on first
48    /// matching intake from the binding's effective `sender_rate_limit`;
49    /// `None` in a slot means "this binding opted out of rate
50    /// limiting". `None` as a key is the legacy bucket synthesised from
51    /// agent-level defaults — its key space stays disjoint from real
52    /// bindings (0..N).
53    ///
54    /// Rationale for per-binding (instead of one per agent): an agent
55    /// that exposes a narrow sales surface on WhatsApp and a trusted
56    /// owner-only surface on Telegram typically wants very different
57    /// throttles, and keeping buckets segregated means flood on one
58    /// channel cannot exhaust the quota on the other.
59    sender_rate_limiters: Arc<DashMap<Option<usize>, Option<Arc<SenderRateLimiter>>>>,
60    /// Pre-resolved per-binding capability policies. The `None` key is
61    /// reserved for the legacy "no bindings" bucket synthesised from
62    /// agent-level defaults. Policies are immutable for the lifetime
63    /// of the runtime so we allocate each one exactly once at `new()`
64    /// time — the hot intake path just clones an `Arc`.
65    effective_policies: Arc<DashMap<Option<usize>, Arc<EffectiveBindingPolicy>>>,
66    /// Phase 18 — hot-reloadable snapshot. Holds the same
67    /// `effective_policies` + `tool_cache` data as the legacy fields
68    /// above, plus the optional per-agent `LlmClient`. The intake
69    /// hot path still reads the legacy fields in this commit; those
70    /// reads migrate to `snapshot.load()` in a follow-up so the
71    /// refactor stays atomic per step.
72    snapshot: Arc<ArcSwap<RuntimeSnapshot>>,
73    /// Base tool registry (plugins + MCP + extensions + skills). Used
74    /// together with `tool_cache` to hand each session a filtered
75    /// `Arc<ToolRegistry>` that only exposes the binding's allowed
76    /// tools. `None` for runtimes spun up without tool wiring (tests,
77    /// no-LLM behaviors). See [`AgentRuntime::with_tool_base`].
78    tool_base: Option<Arc<super::tool_registry::ToolRegistry>>,
79    /// Phase 17 — per-agent credential resolver attached to every
80    /// AgentContext the runtime builds. `None` in tests / no-credential
81    /// boot paths; consumers fall back to legacy topics in that case.
82    credentials: Option<Arc<nexo_auth::AgentCredentialResolver>>,
83    /// Phase 17 — per-(channel, instance) breaker registry; cloned
84    /// onto every AgentContext alongside `credentials`.
85    breakers: Option<Arc<nexo_auth::BreakerRegistry>>,
86    /// Optional pre-persistence redactor cloned onto every
87    /// AgentContext. `None` keeps transcripts un-redacted.
88    redactor: Option<Arc<super::redaction::Redactor>>,
89    /// Optional FTS5 transcripts index cloned onto every AgentContext.
90    /// `None` keeps `session_logs` action `search` on the JSONL
91    /// substring fallback.
92    transcripts_index: Option<Arc<super::transcripts_index::TranscriptsIndex>>,
93    /// Phase 21 — shared link extractor (HTTP client + LRU cache).
94    /// `None` keeps link understanding off regardless of YAML.
95    link_extractor: Option<Arc<crate::link_understanding::LinkExtractor>>,
96    /// Phase 25 — shared web-search router (one per process, every
97    /// runtime gets the same Arc). `None` disables the `web_search`
98    /// tool regardless of YAML.
99    web_search_router: Option<Arc<nexo_web_search::WebSearchRouter>>,
100    /// Phase 26 — shared pairing gate. Consulted in the intake hot
101    /// path before sender_rate_limit; when the resolved
102    /// `EffectiveBindingPolicy::pairing.auto_challenge` is true and
103    /// the sender is not in `pairing_allow_from`, the message is
104    /// dropped and a code is logged for the operator to approve via
105    /// `nexo pair approve`. `None` disables the gate regardless of
106    /// YAML.
107    pairing_gate: Option<Arc<nexo_pairing::PairingGate>>,
108    /// Channel adapter registry consulted alongside `pairing_gate`. The
109    /// registry maps `source_plugin` (`"whatsapp"`, `"telegram"`, …) to
110    /// a `PairingChannelAdapter` so the gate can normalise sender ids
111    /// and so challenge replies can be delivered through the channel-
112    /// specific outbound path. `None` keeps the legacy zero-adapter
113    /// path: senders pass through verbatim and challenges are published
114    /// raw on `plugin.outbound.{channel}`.
115    pairing_adapters: nexo_pairing::PairingAdapterRegistry,
116    /// Phase 67 — shared DispatchToolContext for the tracker /
117    /// dispatch tool family. `None` keeps the dispatch tools
118    /// in their friendly-error mode (handlers return
119    /// "AgentContext.dispatch is not set"). Wire one in at boot
120    /// when the in-process orchestrator + agent-registry are
121    /// available.
122    dispatch_ctx: Option<Arc<super::dispatch_handlers::DispatchToolContext>>,
123    /// Phase 79.1 — process-shared plan-mode approval registry.
124    /// Defaults to a fresh instance per runtime so tests stay
125    /// isolated; main.rs overrides with a single shared instance
126    /// so the broker subscriber can resolve pending approvals from
127    /// inbound `[plan-mode]` chat messages.
128    plan_approval_registry: Arc<super::plan_mode_tool::PlanApprovalRegistry>,
129    /// Legacy cache — still owned by the runtime for back-compat with
130    /// any test construction path. Hot-reload reads the per-snapshot
131    /// `tool_cache` instead; see [`RuntimeSnapshot::tool_cache`].
132    tool_cache: Arc<super::tool_registry_cache::ToolRegistryCache>,
133    /// Phase 18 — reload control channel. The coordinator sends
134    /// `Apply(snapshot)` to atomically swap; the runtime reads the
135    /// new snapshot from the next event onwards (apply-on-next).
136    reload_tx: mpsc::Sender<ReloadCommand>,
137    /// Receiver owned by the runtime until `start()` moves it into
138    /// the select loop. `Option` because it can only be taken once.
139    reload_rx: Arc<Mutex<Option<mpsc::Receiver<ReloadCommand>>>>,
140    /// Phase 82.13.c — operator pause check. When `Some`, the
141    /// inbound intake loop calls `get(scope)` on every inbound;
142    /// `PausedByOperator` triggers `push_pending` instead of
143    /// firing the agent turn. `None` keeps the legacy
144    /// "process every inbound" behaviour for tests + daemons
145    /// without admin RPC. Production wires the same `Arc`
146    /// shared with the admin RPC dispatcher so a pause RPC
147    /// reaches the inbound loop on the next message.
148    processing_store: Option<Arc<dyn ProcessingControlStore>>,
149    /// Phase 82.13.c — agent event firehose (Phase 82.11). When
150    /// `Some` AND `processing_store` evicts an inbound from a
151    /// full pending queue, runtime emits
152    /// `AgentEventKind::PendingInboundsDropped` so the operator
153    /// UI can surface the drop. `None` keeps the eviction
154    /// silent (logs only).
155    event_emitter: Option<Arc<dyn AgentEventEmitter>>,
156    shutdown: CancellationToken,
157    tasks: Arc<Mutex<JoinSet<()>>>,
158}
159
160/// Commands the reload coordinator sends to per-agent runtimes.
161#[derive(Debug)]
162pub enum ReloadCommand {
163    /// Swap in a new snapshot. Picked up by the next event's
164    /// `snapshot.load()` read — in-flight turns keep the old Arc.
165    Apply(Arc<RuntimeSnapshot>),
166}
167impl AgentRuntime {
168    pub fn new(agent: Arc<Agent>, broker: AnyBroker, sessions: Arc<SessionManager>) -> Self {
169        let debounce_ms = Duration::from_millis(agent.config.config.debounce_ms);
170        let queue_cap = agent.config.config.queue_cap;
171        // Pre-resolve the per-binding effective policies so the intake
172        // hot path doesn't allocate. The set is bounded by the number
173        // of bindings (typically 1-3) plus the legacy sentinel slot
174        // for agents that haven't adopted bindings yet.
175        let effective_policies: DashMap<Option<usize>, Arc<EffectiveBindingPolicy>> =
176            DashMap::new();
177        if agent.config.inbound_bindings.is_empty() {
178            effective_policies.insert(
179                None,
180                Arc::new(EffectiveBindingPolicy::from_agent_defaults(&agent.config)),
181            );
182        } else {
183            for idx in 0..agent.config.inbound_bindings.len() {
184                effective_policies.insert(
185                    Some(idx),
186                    EffectiveBindingPolicy::resolved(&agent.config, idx),
187                );
188            }
189        }
190        let initial_snapshot = RuntimeSnapshot::bare(Arc::clone(&agent.config), 0);
191        let (reload_tx, reload_rx) = mpsc::channel(4);
192        Self {
193            agent,
194            broker,
195            sessions,
196            memory: None,
197            peers: None,
198            router: Arc::new(AgentRouter::new()),
199            session_txs: Arc::new(DashMap::new()),
200            debounce_ms,
201            queue_cap,
202            sender_rate_limiters: Arc::new(DashMap::new()),
203            effective_policies: Arc::new(effective_policies),
204            snapshot: Arc::new(ArcSwap::from_pointee(initial_snapshot)),
205            tool_base: None,
206            credentials: None,
207            breakers: None,
208            redactor: None,
209            transcripts_index: None,
210            link_extractor: None,
211            web_search_router: None,
212            pairing_gate: None,
213            pairing_adapters: nexo_pairing::PairingAdapterRegistry::new(),
214            dispatch_ctx: None,
215            plan_approval_registry: Arc::new(super::plan_mode_tool::PlanApprovalRegistry::default()),
216            tool_cache: Arc::new(super::tool_registry_cache::ToolRegistryCache::new()),
217            reload_tx,
218            reload_rx: Arc::new(Mutex::new(Some(reload_rx))),
219            processing_store: None,
220            event_emitter: None,
221            shutdown: CancellationToken::new(),
222            tasks: Arc::new(Mutex::new(JoinSet::new())),
223        }
224    }
225    pub fn with_memory(mut self, memory: Arc<LongTermMemory>) -> Self {
226        self.memory = Some(memory);
227        self
228    }
229    pub fn with_redactor(mut self, redactor: Arc<super::redaction::Redactor>) -> Self {
230        self.redactor = Some(redactor);
231        self
232    }
233
234    /// Phase 82.13.c — install the operator pause-check store.
235    /// Pass the SAME `Arc` boot wired into the admin RPC
236    /// dispatcher so a pause RPC reaches the inbound loop on
237    /// the very next message. Without this builder, the
238    /// runtime processes every inbound regardless of operator
239    /// pause state (legacy / tests).
240    pub fn with_processing_store(mut self, store: Arc<dyn ProcessingControlStore>) -> Self {
241        self.processing_store = Some(store);
242        self
243    }
244
245    /// Phase 82.13.c — install the agent event emitter for
246    /// firehose drop notifications when the per-scope pending
247    /// queue evicts an oldest entry. Production wires the same
248    /// `BroadcastAgentEventEmitter` Phase 82.11 already plumbs
249    /// to `TranscriptWriter`. `None` keeps eviction silent
250    /// (tracing only).
251    pub fn with_event_emitter(mut self, emitter: Arc<dyn AgentEventEmitter>) -> Self {
252        self.event_emitter = Some(emitter);
253        self
254    }
255    pub fn with_transcripts_index(
256        mut self,
257        index: Arc<super::transcripts_index::TranscriptsIndex>,
258    ) -> Self {
259        self.transcripts_index = Some(index);
260        self
261    }
262    /// Attach the shared link extractor. All `AgentContext`s built by
263    /// this runtime inherit it so `llm_behavior` can fetch URLs in
264    /// inbound messages and build the `# LINK CONTEXT` block.
265    pub fn with_link_extractor(
266        mut self,
267        ext: Arc<crate::link_understanding::LinkExtractor>,
268    ) -> Self {
269        self.link_extractor = Some(ext);
270        self
271    }
272    /// Attach the shared web-search router. Every `AgentContext` built
273    /// by this runtime inherits it so the `web_search` tool can route.
274    pub fn with_web_search_router(mut self, router: Arc<nexo_web_search::WebSearchRouter>) -> Self {
275        self.web_search_router = Some(router);
276        self
277    }
278    /// Attach the shared pairing gate. Consulted before the per-sender
279    /// rate limiter in the intake hot path so unknown senders never
280    /// reach the agent's behavior.
281    pub fn with_pairing_gate(mut self, gate: Arc<nexo_pairing::PairingGate>) -> Self {
282        self.pairing_gate = Some(gate);
283        self
284    }
285    /// Attach the pairing channel-adapter registry. Adapters registered
286    /// here are looked up by `source_plugin` on every inbound message
287    /// when the gate is active, and used to normalise sender ids before
288    /// store lookup. `None` (default) preserves legacy zero-adapter
289    /// behaviour.
290    pub fn with_pairing_adapters(mut self, registry: nexo_pairing::PairingAdapterRegistry) -> Self {
291        self.pairing_adapters = registry;
292        self
293    }
294    /// Phase 67 — install the DispatchToolContext shared by every
295    /// AgentContext this runtime builds. Without this the dispatch
296    /// tool handlers return a friendly error.
297    pub fn with_dispatch_ctx(
298        mut self,
299        ctx: Arc<super::dispatch_handlers::DispatchToolContext>,
300    ) -> Self {
301        self.dispatch_ctx = Some(ctx);
302        self
303    }
304    /// Phase 79.1 — install a process-shared plan-mode approval
305    /// registry. Production wiring creates one per process and passes
306    /// it here so the broker subscriber can resolve pending approvals
307    /// from inbound `[plan-mode]` chat messages.
308    pub fn with_plan_approval_registry(
309        mut self,
310        registry: Arc<super::plan_mode_tool::PlanApprovalRegistry>,
311    ) -> Self {
312        self.plan_approval_registry = registry;
313        self
314    }
315    pub fn with_peers(mut self, peers: Arc<PeerDirectory>) -> Self {
316        self.peers = Some(peers);
317        self
318    }
319    /// Attach the base tool registry used by this agent so the runtime
320    /// can hand each session a per-binding filtered view via its
321    /// internal cache. Without this, sessions fall back to the
322    /// behavior's own registry and pay a per-turn filter cost.
323    pub fn with_tool_base(mut self, tools: Arc<super::tool_registry::ToolRegistry>) -> Self {
324        self.tool_base = Some(tools);
325        self
326    }
327    /// Expose the runtime's `ArcSwap<RuntimeSnapshot>` so the reload
328    /// coordinator can swap a freshly-built snapshot in atomically
329    /// without tearing down the runtime. Cheap `Arc` clone — callers
330    /// typically stash the handle once at boot.
331    pub fn snapshot_handle(&self) -> Arc<ArcSwap<RuntimeSnapshot>> {
332        Arc::clone(&self.snapshot)
333    }
334    /// Atomic swap of the per-agent snapshot. Readers that already
335    /// hold an `Arc<RuntimeSnapshot>` (session tasks mid-turn) keep
336    /// their copy for the lifetime of that Arc; subsequent
337    /// `snapshot.load()` calls see the new value.
338    pub fn swap_snapshot(&self, new: Arc<RuntimeSnapshot>) {
339        self.snapshot.store(new);
340    }
341    /// Clone the `ReloadCommand` sender so the coordinator can push
342    /// `Apply` commands. One sender per agent runtime; the receiver is
343    /// drained inside `start()`.
344    pub fn reload_sender(&self) -> mpsc::Sender<ReloadCommand> {
345        self.reload_tx.clone()
346    }
347    /// Attach the credential resolver. All `AgentContext`s built by
348    /// this runtime inherit it so outbound tools can look up the
349    /// agent's bound instance instead of publishing to the legacy
350    /// single-account topic.
351    pub fn with_credentials(
352        mut self,
353        credentials: Arc<nexo_auth::AgentCredentialResolver>,
354    ) -> Self {
355        self.credentials = Some(credentials);
356        self
357    }
358    pub fn with_breakers(mut self, breakers: Arc<nexo_auth::BreakerRegistry>) -> Self {
359        self.breakers = Some(breakers);
360        self
361    }
362    pub fn router(&self) -> Arc<AgentRouter> {
363        Arc::clone(&self.router)
364    }
365    pub async fn start(&self) -> anyhow::Result<()> {
366        let plugin_topic = "plugin.inbound.>";
367        let mut plugin_sub = self.broker.subscribe(plugin_topic).await?;
368        // Phase 18 — take the reload receiver exactly once. Subsequent
369        // start() calls on the same runtime would starve reload; the
370        // None branch logs a warn instead of panicking to keep test
371        // code that re-starts runtimes honest.
372        let reload_rx = self.reload_rx.lock().await.take();
373        if reload_rx.is_none() {
374            tracing::warn!(
375                agent_id = %self.agent.id,
376                "reload receiver already taken — hot-reload disabled for this runtime start"
377            );
378        }
379        let mut reload_rx = reload_rx;
380        let snapshot = Arc::clone(&self.snapshot);
381        let heartbeat_topic = heartbeat_topic(&self.agent.id);
382        let mut heartbeat_sub = self.broker.subscribe(&heartbeat_topic).await?;
383        let route_inbound_topic = route_topic(&self.agent.id);
384        let mut route_sub = self.broker.subscribe(&route_inbound_topic).await?;
385        let agent = Arc::clone(&self.agent);
386        let sessions = Arc::clone(&self.sessions);
387        let broker = self.broker.clone();
388        let memory = self.memory.clone();
389        let peers = self.peers.clone();
390        let credentials = self.credentials.clone();
391        let breakers = self.breakers.clone();
392        let redactor = self.redactor.clone();
393        let transcripts_index = self.transcripts_index.clone();
394        let link_extractor = self.link_extractor.clone();
395        let web_search_router = self.web_search_router.clone();
396        let pairing_gate = self.pairing_gate.clone();
397        let pairing_adapters = self.pairing_adapters.clone();
398        let dispatch_ctx = self.dispatch_ctx.clone();
399        // Phase 82.13.c — clone into the spawn closure. Both
400        // are `Option<Arc<dyn _>>`, so cloning is cheap and
401        // `None` defaults preserve legacy behaviour.
402        let processing_store = self.processing_store.clone();
403        let event_emitter = self.event_emitter.clone();
404        let router = Arc::clone(&self.router);
405        let session_txs = Arc::clone(&self.session_txs);
406        let debounce_ms = self.debounce_ms;
407        let queue_cap = self.queue_cap;
408        let sender_rate_limiters = Arc::clone(&self.sender_rate_limiters);
409        let effective_policies = Arc::clone(&self.effective_policies);
410        // Phase 18 — every event reads the current snapshot so hot-
411        // reload takes effect immediately on the next message without
412        // touching the legacy per-runtime caches (kept around during
413        // the migration so tests that construct runtimes without a
414        // coordinator still work).
415        let snapshot_ref = Arc::clone(&self.snapshot);
416        let tool_base = self.tool_base.clone();
417        let _tool_cache = Arc::clone(&self.tool_cache);
418        let plan_approval_registry = Arc::clone(&self.plan_approval_registry);
419        let shutdown = self.shutdown.clone();
420        let tasks = Arc::clone(&self.tasks);
421        let shutdown2 = shutdown.clone();
422        self.tasks.lock().await.spawn(async move {
423            let mut ctx = AgentContext::new(
424                agent.id.clone(),
425                Arc::clone(&agent.config),
426                broker.clone(),
427                Arc::clone(&sessions),
428            );
429            if let Some(ref mem) = memory {
430                ctx = ctx.with_memory(Arc::clone(mem));
431            }
432            if let Some(ref p) = peers {
433                ctx = ctx.with_peers(Arc::clone(p));
434            }
435            if let Some(ref c) = credentials {
436                ctx = ctx.with_credentials(Arc::clone(c));
437            }
438            if let Some(ref b) = breakers {
439                ctx = ctx.with_breakers(Arc::clone(b));
440            }
441            if let Some(ref r) = redactor {
442                ctx = ctx.with_redactor(Arc::clone(r));
443            }
444            if let Some(ref ext) = link_extractor {
445                ctx = ctx.with_link_extractor(Arc::clone(ext));
446            }
447            if let Some(ref ws) = web_search_router {
448                ctx = ctx.with_web_search_router(Arc::clone(ws));
449            }
450            if let Some(ref idx) = transcripts_index {
451                ctx = ctx.with_transcripts_index(Arc::clone(idx));
452            }
453            if let Some(ref dc) = dispatch_ctx {
454                ctx = ctx.with_dispatch(Arc::clone(dc));
455            }
456            // Phase 82.11.c — thread the firehose emitter so
457            // `llm_behavior` can chain `.with_emitter()` on the
458            // per-turn `TranscriptWriter` and broadcast
459            // `TranscriptAppended` to live subscribers.
460            if let Some(ref em) = event_emitter {
461                ctx = ctx.with_event_emitter(em.clone());
462            }
463            ctx = ctx.with_router(Arc::clone(&router));
464            ctx = ctx.with_plan_approval_registry(plan_approval_registry.clone());
465            ctx = ctx.with_context_optimization(snapshot.load().context_optimization);
466            loop {
467                tokio::select! {
468                    biased;
469                    // Phase 18 — reload command drains first so a
470                    // burst of inbound events can't starve a pending
471                    // config swap. `biased` keeps arm ordering stable.
472                    cmd = async {
473                        match reload_rx.as_mut() {
474                            Some(rx) => rx.recv().await,
475                            None => std::future::pending().await,
476                        }
477                    } => {
478                        match cmd {
479                            Some(ReloadCommand::Apply(new_snap)) => {
480                                let version = new_snap.version;
481                                snapshot.store(new_snap);
482                                crate::telemetry::set_runtime_config_version(&agent.id, version);
483                                // The aggregate counter is bumped
484                                // once per reload by the coordinator;
485                                // the per-agent gauge above is what
486                                // dashboards correlate with sessions.
487                                tracing::info!(
488                                    agent_id = %agent.id,
489                                    version,
490                                    "config reload: snapshot applied",
491                                );
492                            }
493                            None => {
494                                tracing::debug!(agent_id = %agent.id, "reload channel closed");
495                                // Channel closed just means the
496                                // coordinator went away; keep serving
497                                // with the current snapshot.
498                                reload_rx = None;
499                            }
500                        }
501                    }
502                    event = plugin_sub.next() => {
503                        let Some(event) = event else { break };
504                        let session_id = event.session_id.unwrap_or_else(Uuid::new_v4);
505                        let text = event.payload
506                            .get("text")
507                            .and_then(|v| v.as_str())
508                            .unwrap_or("")
509                            .to_string();
510                        let (source_plugin, source_instance) =
511                            parse_inbound_topic(&event.topic);
512                        // Binding filter — strict allowlist. An agent
513                        // with no `inbound_bindings` no longer falls
514                        // into a "legacy wildcard" bucket; every
515                        // operator must declare what their agent
516                        // listens to. Earlier behavior (empty list →
517                        // accept everything) silently swallowed every
518                        // plugin event when a wizard-generated
519                        // override happened to omit the bindings, so
520                        // a single bot's messages reached every agent
521                        // sharing the channel. The match also returns
522                        // the binding index so the session task can
523                        // pick up its per-binding capability overrides
524                        // (tools, outbound allowlist, skills, model,
525                        // prompt, rate limit, delegates). Load once
526                        // per event so an in-flight reload
527                        // (ReloadCommand::Apply racing against the
528                        // event) can't give us a partial view: we
529                        // either see the old snapshot fully or the
530                        // new one fully. Matches the apply-on-next
531                        // semantic — a reload that swaps while an
532                        // event is being *parsed* still gets applied
533                        // on the NEXT event because biased select
534                        // drains reload first.
535                        let snap = snapshot_ref.load_full();
536                        let bindings = &snap.nexo_config.inbound_bindings;
537                        let effective = match match_binding_index(
538                            bindings,
539                            &source_plugin,
540                            source_instance.as_deref(),
541                        ) {
542                            Some(idx) => {
543                                tracing::trace!(
544                                    agent_id = %agent.id,
545                                    plugin = %source_plugin,
546                                    instance = source_instance.as_deref().unwrap_or("-"),
547                                    binding_index = idx,
548                                    snapshot_version = snap.version,
549                                    "inbound matched binding",
550                                );
551                                snap.policy_for(Some(idx))
552                                    .or_else(|| effective_policies.get(&Some(idx)).map(|e| Arc::clone(e.value())))
553                                    .expect("per-binding effective policy is seeded at runtime::new")
554                            }
555                            None => {
556                                tracing::trace!(
557                                    agent_id = %agent.id,
558                                    plugin = %source_plugin,
559                                    instance = source_instance.as_deref().unwrap_or("-"),
560                                    bindings_len = bindings.len(),
561                                    "inbound dropped by binding filter",
562                                );
563                                continue;
564                            }
565                        };
566                        let sender_id = event.payload
567                            .get("from")
568                            .and_then(|v| v.as_str())
569                            .map(|s| s.to_string());
570                        let reply_question_id = event
571                            .payload
572                            .get("reply_to_question_id")
573                            .and_then(|v| v.as_str())
574                            .or_else(|| {
575                                event
576                                    .payload
577                                    .get("ask_question_id")
578                                    .and_then(|v| v.as_str())
579                            })
580                            .map(|s| s.to_string());
581                        // Phase 77.16 — AskUserQuestion reply routing.
582                        // If this inbound message comes from a sender that has
583                        // a paused goal waiting on a question, resume that goal
584                        // and inject the text as an operator interrupt.
585                        if let (Some(dc), Some(sender), true) = (
586                            dispatch_ctx.as_ref(),
587                            sender_id.as_deref(),
588                            !text.is_empty(),
589                        ) {
590                            let inst = source_instance.as_deref().unwrap_or("default");
591                            let waiting_goal = match reply_question_id.as_deref() {
592                                Some(qid) => dc.registry.find_paused_by_question_id(
593                                    &source_plugin,
594                                    inst,
595                                    sender,
596                                    qid,
597                                ),
598                                None => dc
599                                    .registry
600                                    .find_paused_by_origin(&source_plugin, inst, sender),
601                            };
602                            if let Some(waiting_goal) = waiting_goal
603                            {
604                                let Some(waiting_handle) = dc.registry.handle(waiting_goal) else {
605                                    continue;
606                                };
607                                let Some(pending) = waiting_handle.snapshot.ask_pending.clone() else {
608                                    // Paused for a different reason; do not auto-resume.
609                                    continue;
610                                };
611                                let queued = dc.orchestrator.interrupt_goal(waiting_goal, format!(
612                                    "[ask_user_question reply id={} from {source_plugin}:{inst}:{sender}] {text}",
613                                    pending.question_id
614                                ));
615                                let resumed = dc.orchestrator.resume_goal(waiting_goal);
616                                if resumed {
617                                    let _ = dc.registry.set_ask_pending(waiting_goal, None).await;
618                                    let _ = dc
619                                        .registry
620                                        .set_status(
621                                            waiting_goal,
622                                            nexo_agent_registry::AgentRunStatus::Running,
623                                        )
624                                        .await;
625                                }
626                                tracing::info!(
627                                    agent_id = %agent.id,
628                                    goal_id = ?waiting_goal,
629                                    question_id = %pending.question_id,
630                                    plugin = %source_plugin,
631                                    instance = %inst,
632                                    sender = %sender,
633                                    queued_interrupts = queued,
634                                    resumed,
635                                    "ask_user_question reply routed to paused goal"
636                                );
637                                continue;
638                            }
639                        }
640                        // Phase 26 — pairing gate. Runs before the
641                        // rate limiter so unknown senders cannot
642                        // exhaust their bucket. Only active when the
643                        // binding's effective `pairing.auto_challenge`
644                        // is true; otherwise the gate fast-paths to
645                        // Admit at zero overhead. The challenge code
646                        // is logged (operator approves via `nexo pair
647                        // approve`); a future pass will publish it
648                        // back through the channel adapter so the
649                        // sender sees it in their chat.
650                        let mut sender_trusted = false;
651                        if effective.pairing.auto_challenge {
652                            if let (Some(gate), Some(sender)) =
653                                (pairing_gate.as_ref(), sender_id.as_deref())
654                            {
655                                let channel = source_plugin.as_str();
656                                let account = source_instance.as_deref().unwrap_or("default");
657                                let adapter = pairing_adapters.get(channel);
658                                match gate
659                                    .should_admit(
660                                        channel,
661                                        account,
662                                        sender,
663                                        &effective.pairing,
664                                        adapter
665                                            .as_deref()
666                                            .map(|a| a as &dyn nexo_pairing::PairingChannelAdapter),
667                                    )
668                                    .await
669                                {
670                                    Ok(nexo_pairing::Decision::Admit) => {
671                                        sender_trusted = true;
672                                    }
673                                    Ok(nexo_pairing::Decision::Challenge { code }) => {
674                                        tracing::warn!(
675                                            agent_id = %agent.id,
676                                            channel,
677                                            account,
678                                            sender,
679                                            code = %code,
680                                            "[intake] pairing challenge issued — run `nexo pair approve {}` to admit, or `nexo pair seed {} {} {}` to skip the challenge",
681                                            code,
682                                            channel,
683                                            account,
684                                            sender,
685                                        );
686                                        deliver_pairing_challenge(
687                                            &broker,
688                                            adapter.as_deref(),
689                                            channel,
690                                            source_instance.as_deref(),
691                                            account,
692                                            sender,
693                                            &code,
694                                        )
695                                        .await;
696                                        continue;
697                                    }
698                                    Ok(nexo_pairing::Decision::Drop) => {
699                                        tracing::trace!(
700                                            agent_id = %agent.id,
701                                            channel,
702                                            account,
703                                            sender,
704                                            "[intake] pairing gate dropped (max-pending exhausted)",
705                                        );
706                                        continue;
707                                    }
708                                    Err(e) => {
709                                        tracing::warn!(
710                                            agent_id = %agent.id,
711                                            error = %e,
712                                            "[intake] pairing gate storage error — admitting fail-open",
713                                        );
714                                    }
715                                }
716                            }
717                        }
718                        // Per-sender rate limit — applied after the
719                        // binding filter so we don't waste bucket
720                        // tokens on events the agent would drop anyway.
721                        // A denied event is silently dropped (trace-
722                        // logged) so the sender doesn't get a "rate
723                        // limited" reply they could use to probe the
724                        // bot. Limiter is per-binding, built lazily
725                        // from the effective `sender_rate_limit`.
726                        let limiter_slot = sender_rate_limiters
727                            .entry(effective.binding_index)
728                            .or_insert_with(|| {
729                                effective
730                                    .sender_rate_limit
731                                    .clone()
732                                    .map(|cfg| Arc::new(SenderRateLimiter::new(cfg)))
733                            })
734                            .value()
735                            .clone();
736                        if let Some(rl) = limiter_slot {
737                            if !rl.try_acquire(&agent.id, sender_id.as_deref()).await {
738                                tracing::trace!(
739                                    agent_id = %agent.id,
740                                    plugin = %source_plugin,
741                                    sender = sender_id.as_deref().unwrap_or("-"),
742                                    binding_index = ?effective.binding_index,
743                                    "inbound dropped by sender rate limit",
744                                );
745                                continue;
746                            }
747                        }
748                        let media = extract_inbound_media(&event.payload);
749                        // Drop events with no text and no media — e.g. reactions,
750                        // receipts, typing, poll votes reach us as empty-text
751                        // InboundEvent::Message. Without this gate the LLM gets
752                        // invoked on empty input and produces spontaneous "¿en
753                        // qué ayudo?" replies (see startup spam bug).
754                        if text.is_empty() && media.is_none() {
755                            tracing::trace!(
756                                agent_id = %agent.id,
757                                plugin = %source_plugin,
758                                "inbound dropped: no text and no media",
759                            );
760                            continue;
761                        }
762                        let mut msg = InboundMessage::new(session_id, &agent.id, text);
763                        msg.source_plugin = source_plugin;
764                        msg.source_instance = source_instance;
765                        msg.sender_id = sender_id;
766                        msg.media = media;
767                        msg.priority = parse_inbound_priority(&event.payload);
768                        msg.sender_trusted = sender_trusted;
769                        // Phase 82.5 — provider-agnostic inbound meta
770                        // built from the raw payload (works for whatsapp
771                        // today; same shape extends to telegram/email/
772                        // future channels without code change).
773                        msg.inbound = extract_inbound_meta(
774                            &event.payload,
775                            msg.sender_id.as_deref(),
776                            msg.media.is_some(),
777                        );
778                        let message_id = msg.id;
779                        // Phase 82.13.c — operator pause check.
780                        // When the admin RPC dispatcher has marked
781                        // this conversation as `PausedByOperator`,
782                        // buffer the inbound onto the per-scope
783                        // pending queue instead of firing an agent
784                        // turn. `resume()` later drains the queue
785                        // onto the transcript as `User` entries
786                        // (Phase 82.13.b.3.2).
787                        if let Some(ps) = processing_store.as_ref() {
788                            let scope = ProcessingScope::Conversation {
789                                agent_id: agent.id.clone(),
790                                channel: msg.source_plugin.clone(),
791                                account_id: msg
792                                    .source_instance
793                                    .clone()
794                                    .unwrap_or_else(|| "default".into()),
795                                contact_id: msg
796                                    .sender_id
797                                    .clone()
798                                    .unwrap_or_else(|| "unknown".into()),
799                                mcp_channel_source: None,
800                            };
801                            let paused = match ps.get(&scope).await {
802                                Ok(ProcessingControlState::PausedByOperator { .. }) => true,
803                                Ok(_) => false,
804                                Err(e) => {
805                                    // Fail-open: a broken store
806                                    // must not freeze the whole
807                                    // inbound loop. Worst case the
808                                    // operator's pause briefly
809                                    // leaks one inbound through.
810                                    tracing::warn!(
811                                        error = %e,
812                                        agent_id = %agent.id,
813                                        "processing_store.get failed; treating as not paused",
814                                    );
815                                    false
816                                }
817                            };
818                            if paused {
819                                // Redact body BEFORE pushing —
820                                // keeps PII out of the queue
821                                // (cap-bounded in-memory now,
822                                // future durable SQLite store
823                                // down the line).
824                                let redacted_body = if let Some(r) = redactor.as_ref() {
825                                    r.apply(&msg.text).redacted_text
826                                } else {
827                                    msg.text.clone()
828                                };
829                                let pending = PendingInbound {
830                                    message_id: Some(msg.id),
831                                    from_contact_id: msg
832                                        .sender_id
833                                        .clone()
834                                        .unwrap_or_else(|| "unknown".into()),
835                                    body: redacted_body,
836                                    timestamp_ms: msg
837                                        .timestamp
838                                        .timestamp_millis()
839                                        .max(0)
840                                        as u64,
841                                    source_plugin: msg.source_plugin.clone(),
842                                };
843                                match ps.push_pending(&scope, pending).await {
844                                    Ok((depth, dropped)) => {
845                                        tracing::debug!(
846                                            agent_id = %agent.id,
847                                            session_id = %session_id,
848                                            depth,
849                                            dropped,
850                                            "inbound buffered while paused",
851                                        );
852                                        if dropped > 0 {
853                                            if let Some(em) = event_emitter.as_ref() {
854                                                let now_ms = std::time::SystemTime::now()
855                                                    .duration_since(std::time::UNIX_EPOCH)
856                                                    .map(|d| d.as_millis() as u64)
857                                                    .unwrap_or(0);
858                                                em.emit(AgentEventKind::PendingInboundsDropped {
859                                                    agent_id: agent.id.clone(),
860                                                    scope: scope.clone(),
861                                                    dropped,
862                                                    at_ms: now_ms,
863                                                })
864                                                .await;
865                                            }
866                                        }
867                                        // Skip session-spawn +
868                                        // try_send when the push
869                                        // succeeded. Resume drain
870                                        // (Phase 82.13.b.3.2) will
871                                        // stamp this onto the
872                                        // transcript as a `User`
873                                        // entry preserving the
874                                        // original timestamp.
875                                        continue;
876                                    }
877                                    Err(e) => {
878                                        // Fail-open: a broken store
879                                        // must not block messages.
880                                        // Raw msg falls through to
881                                        // the session channel below
882                                        // — agent will process it.
883                                        // Logging only.
884                                        tracing::warn!(
885                                            error = %e,
886                                            agent_id = %agent.id,
887                                            "push_pending failed; inbound will fire turn",
888                                        );
889                                        // No `continue` — fall
890                                        // through to the session
891                                        // spawn block.
892                                    }
893                                }
894                            }
895                        }
896                        // Atomic get-or-insert: DashMap::entry::or_insert_with
897                        // guarantees only one task is spawned per session even
898                        // when two threads race the first message for a new
899                        // session_id. The spawned task also receives the
900                        // session_txs handle so it can remove its own entry
901                        // on exit — otherwise the map grows without bound as
902                        // sessions come and go (one per chat, forever).
903                        // Atomic get-or-insert: DashMap::entry::or_insert_with
904                        // guarantees only one task is spawned per session even
905                        // when two threads race the first message for a new
906                        // session_id. The spawned task receives its own tx
907                        // handle so it can remove exactly its own entry from
908                        // the map on exit (the `same_channel` check avoids a
909                        // race where a newer session replaced us).
910                        let effective_for_session = Arc::clone(&effective);
911                        // Pre-filtered tool registry for this binding.
912                        // Pulls the cache from the active snapshot so a
913                        // reload that changed allowed_tools produces a
914                        // fresh filtered clone (old snapshot's cache
915                        // stays with its in-flight sessions). `None`
916                        // base registry (tests) → llm_behavior falls
917                        // back to its own tool set.
918                        // PT-2 — use the dispatch-aware variant so the
919                        // per-binding registry also drops dispatch
920                        // tools the resolved DispatchPolicy does not
921                        // allow. is_admin defaults to false until the
922                        // operator-bit is plumbed through binding
923                        // resolution; admin tools stay available
924                        // through the legacy bin until then.
925                        let effective_tools_for_session = tool_base.as_ref().map(|base| {
926                            snap.tool_cache.get_or_build_with_dispatch(
927                                &agent.id,
928                                effective_for_session.binding_index,
929                                base,
930                                &effective_for_session.allowed_tools,
931                                &effective_for_session.dispatch_policy,
932                                false,
933                            )
934                        });
935                        let entry = session_txs.entry(session_id).or_insert_with(|| {
936                            let (tx, rx) = mpsc::channel(queue_cap);
937                            let tx_for_task = tx.clone();
938                            let mut ctx = AgentContext::new(
939                                agent.id.clone(),
940                                Arc::clone(&agent.config),
941                                broker.clone(),
942                                Arc::clone(&sessions),
943                            );
944                            ctx = ctx.with_effective(Arc::clone(&effective_for_session));
945                            // Phase 82.4.b.b — populate
946                            // `BindingContext.event_source` when the
947                            // inbound was synthesised by an
948                            // EventSubscriber. Gate on the topic
949                            // prefix to avoid debug-log spam on
950                            // native-channel inbounds.
951                            if event.topic.starts_with(
952                                crate::agent::event_subscriber::EVENT_INBOUND_TOPIC_PREFIX,
953                            ) && ctx.binding.is_some()
954                            {
955                                if let Some(meta) =
956                                    crate::agent::event_subscriber::extract_nexo_event_source(
957                                        &event.payload,
958                                    )
959                                {
960                                    ctx = ctx.with_event_source(meta);
961                                }
962                            }
963                            ctx = ctx.with_plan_approval_registry(plan_approval_registry.clone());
964                            ctx = ctx.with_context_optimization(snap.context_optimization);
965                            if let Some(tools) = effective_tools_for_session.clone() {
966                                ctx = ctx.with_effective_tools(tools);
967                            }
968                            if let Some(ref mem) = memory {
969                                ctx = ctx.with_memory(Arc::clone(mem));
970                            }
971                            if let Some(ref p) = peers {
972                                ctx = ctx.with_peers(Arc::clone(p));
973                            }
974                            if let Some(ref c) = credentials {
975                                ctx = ctx.with_credentials(Arc::clone(c));
976                            }
977                            if let Some(ref r) = redactor {
978                                ctx = ctx.with_redactor(Arc::clone(r));
979                            }
980                            if let Some(ref idx) = transcripts_index {
981                                ctx = ctx.with_transcripts_index(Arc::clone(idx));
982                            }
983                            if let Some(ref ext) = link_extractor {
984                                ctx = ctx.with_link_extractor(Arc::clone(ext));
985                            }
986                            if let Some(ref ws) = web_search_router {
987                                ctx = ctx.with_web_search_router(Arc::clone(ws));
988                            }
989                            // Phase 67 — share the DispatchToolContext
990                            // so program_phase / list_agents / etc.
991                            // see the runtime services on every session.
992                            if let Some(ref dc) = dispatch_ctx {
993                                ctx = ctx.with_dispatch(Arc::clone(dc));
994                            }
995                            // Phase 82.11.c — same firehose-emitter
996                            // thread on the per-session ctx as the
997                            // primary spawn site, so the second
998                            // intake path (event-bus subscriber)
999                            // also broadcasts transcript appends.
1000                            if let Some(ref em) = event_emitter {
1001                                ctx = ctx.with_event_emitter(em.clone());
1002                            }
1003                            let behavior = Arc::clone(&agent.behavior);
1004                            let cancel = shutdown.clone();
1005                            let session_txs_for_task = Arc::clone(&session_txs);
1006                            let tasks_for_spawn = Arc::clone(&tasks);
1007                            // Spawn without holding the tasks lock across
1008                            // `await` to avoid deadlock with `stop()`.
1009                            // Also short-circuit if shutdown has already
1010                            // fired: `stop()` may have taken the lock and
1011                            // started draining before this outer spawn
1012                            // got scheduled, in which case a late
1013                            // register would leak a joined-off task.
1014                            let cancel_for_outer = shutdown.clone();
1015                            tokio::spawn(async move {
1016                                if cancel_for_outer.is_cancelled() {
1017                                    return;
1018                                }
1019                                let mut tasks_guard = tasks_for_spawn.lock().await;
1020                                if cancel_for_outer.is_cancelled() {
1021                                    return;
1022                                }
1023                                let _jh = tasks_guard.spawn(
1024                                    session_debounce_task(
1025                                        rx,
1026                                        behavior,
1027                                        ctx,
1028                                        debounce_ms,
1029                                        cancel,
1030                                        session_id,
1031                                        session_txs_for_task,
1032                                        tx_for_task,
1033                                    ),
1034                                );
1035                            });
1036                            tx
1037                        });
1038                        let tx = entry.value().clone();
1039                        drop(entry);
1040                        if let Err(e) = tx.try_send(msg) {
1041                            tracing::warn!(
1042                                agent_id = %agent.id,
1043                                session_id = %session_id,
1044                                message_id = %message_id,
1045                                error = %e,
1046                                "session queue full — message dropped"
1047                            );
1048                        }
1049                    }
1050                    event = heartbeat_sub.next() => {
1051                        let Some(event) = event else { break };
1052                        tracing::debug!(
1053                            agent_id = %agent.id,
1054                            event_id = %event.id,
1055                            "heartbeat tick received"
1056                        );
1057                        ctx = ctx.with_context_optimization(snapshot_ref.load().context_optimization);
1058                        if let Err(e) = agent.behavior.on_heartbeat(&ctx).await {
1059                            tracing::error!(agent_id = %agent.id, error = %e, "on_heartbeat failed");
1060                        }
1061                    }
1062                    event = route_sub.next() => {
1063                        let Some(event) = event else { break };
1064                        let msg: AgentMessage = match serde_json::from_value(event.payload.clone()) {
1065                            Ok(m) => m,
1066                            Err(e) => {
1067                                tracing::warn!(agent_id = %agent.id, error = %e, "invalid route payload");
1068                                continue;
1069                            }
1070                        };
1071                        if msg.to != agent.id {
1072                            continue;
1073                        }
1074                        match msg.payload {
1075                            AgentPayload::Delegate { task, context } => {
1076                                // Receiver-side authorization: enforces
1077                                // `accept_delegates_from` so a
1078                                // compromised peer can't bypass the
1079                                // caller's `allowed_delegates` gate by
1080                                // publishing directly to the broker.
1081                                let acl = &agent.config.accept_delegates_from;
1082                                if !acl.is_empty()
1083                                    && !acl.iter().any(|p| match p.strip_suffix('*') {
1084                                        Some(stem) => msg.from.starts_with(stem),
1085                                        None => p == &msg.from,
1086                                    })
1087                                {
1088                                    tracing::warn!(
1089                                        agent_id = %agent.id,
1090                                        from = %msg.from,
1091                                        correlation_id = %msg.correlation_id,
1092                                        "delegate rejected: sender not in accept_delegates_from"
1093                                    );
1094                                    let response = AgentMessage {
1095                                        from: agent.id.clone(),
1096                                        to: msg.from.clone(),
1097                                        correlation_id: msg.correlation_id,
1098                                        payload: AgentPayload::Result {
1099                                            task_id: msg.correlation_id,
1100                                            output: serde_json::json!({
1101                                                "error": "delegate rejected by receiver ACL",
1102                                            }),
1103                                        },
1104                                    };
1105                                    let topic = route_topic(&msg.from);
1106                                    if let Ok(payload) = serde_json::to_value(response) {
1107                                        let evt = nexo_broker::Event::new(
1108                                            &topic,
1109                                            &agent.id,
1110                                            payload,
1111                                        );
1112                                        let _ = broker.publish(&topic, evt).await;
1113                                    }
1114                                    continue;
1115                                }
1116                                let session_id = parse_session_id_from_context(&context).unwrap_or_else(Uuid::new_v4);
1117                                let mut inbound = InboundMessage::new(session_id, &agent.id, task);
1118                                inbound.trigger = RunTrigger::Manual;
1119                                inbound.source_plugin = "agent".to_string();
1120                                inbound.sender_id = Some(msg.from.clone());
1121                                // Phase 82.5 — delegation receive surfaces as
1122                                // `InboundKind::InterSession` so a microapp
1123                                // can branch on origin (peer agent vs end
1124                                // user). `correlation_id` is the per-request
1125                                // token from the calling peer; carrying it as
1126                                // `origin_session_id` lets the receiver
1127                                // reconstruct the delegation graph in audit
1128                                // logs.
1129                                inbound.inbound = Some(
1130                                    nexo_tool_meta::InboundMessageMeta::inter_session(
1131                                        msg.correlation_id,
1132                                    )
1133                                    .with_ts(chrono::Utc::now()),
1134                                );
1135                                tracing::info!(
1136                                    agent_id = %agent.id,
1137                                    from = %msg.from,
1138                                    to = %msg.to,
1139                                    correlation_id = %msg.correlation_id,
1140                                    session_id = %session_id,
1141                                    message_id = %inbound.id,
1142                                    "route delegate received"
1143                                );
1144                                let output = match agent.behavior.decide(&ctx, &inbound).await {
1145                                    Ok(text) => serde_json::json!({ "text": text }),
1146                                    Err(e) => serde_json::json!({ "error": e.to_string() }),
1147                                };
1148                                let response = AgentMessage {
1149                                    from: agent.id.clone(),
1150                                    to: msg.from.clone(),
1151                                    correlation_id: msg.correlation_id,
1152                                    payload: AgentPayload::Result {
1153                                        task_id: msg.correlation_id,
1154                                        output,
1155                                    },
1156                                };
1157                                let topic = route_topic(&msg.from);
1158                                let payload = match serde_json::to_value(response) {
1159                                    Ok(v) => v,
1160                                    Err(e) => {
1161                                        tracing::error!(agent_id = %agent.id, error = %e, "failed to serialize route result");
1162                                        continue;
1163                                    }
1164                                };
1165                                let evt = nexo_broker::Event::new(&topic, &agent.id, payload);
1166                                if let Err(e) = broker.publish(&topic, evt).await {
1167                                    tracing::error!(agent_id = %agent.id, error = %e, "failed to publish route result");
1168                                } else {
1169                                    tracing::info!(
1170                                        agent_id = %agent.id,
1171                                        to = %msg.from,
1172                                        correlation_id = %msg.correlation_id,
1173                                        "route result published"
1174                                    );
1175                                }
1176                            }
1177                            AgentPayload::Result { output, .. } => {
1178                                if let Some(router) = ctx.router.as_ref() {
1179                                    let resumed = router.resolve(msg.correlation_id, output);
1180                                    if !resumed {
1181                                        tracing::debug!(
1182                                            agent_id = %agent.id,
1183                                            correlation_id = %msg.correlation_id,
1184                                            "route result had no pending waiter"
1185                                        );
1186                                    } else {
1187                                        tracing::info!(
1188                                            agent_id = %agent.id,
1189                                            from = %msg.from,
1190                                            correlation_id = %msg.correlation_id,
1191                                            "route result matched pending waiter"
1192                                        );
1193                                    }
1194                                }
1195                            }
1196                            AgentPayload::Broadcast { event, data } => {
1197                                let evt = nexo_broker::Event::new(
1198                                    format!("agent.broadcast.{event}"),
1199                                    &msg.from,
1200                                    data,
1201                                );
1202                                if let Err(e) = agent.behavior.on_event(&ctx, evt).await {
1203                                    tracing::error!(agent_id = %agent.id, error = %e, "on_event failed for route broadcast");
1204                                }
1205                            }
1206                        }
1207                    }
1208                    _ = shutdown2.cancelled() => break,
1209                }
1210            }
1211        });
1212        if let Some(interval) = heartbeat_interval(&self.agent.config)? {
1213            let broker = self.broker.clone();
1214            let agent_id = self.agent.id.clone();
1215            let shutdown = self.shutdown.clone();
1216            self.tasks.lock().await.spawn(async move {
1217                // Delay first tick by `interval` so the agent doesn't fire
1218                // on_heartbeat immediately on boot (which causes proactive
1219                // messages / reminders to spam on startup).
1220                let mut ticker = tokio::time::interval_at(
1221                    tokio::time::Instant::now() + interval,
1222                    interval,
1223                );
1224                ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1225                loop {
1226                    tokio::select! {
1227                        _ = shutdown.cancelled() => break,
1228                        _ = ticker.tick() => {
1229                            if let Err(e) = publish_heartbeat(&broker, &agent_id).await {
1230                                tracing::error!(agent_id = %agent_id, error = %e, "failed to publish heartbeat");
1231                            }
1232                        }
1233                    }
1234                }
1235            });
1236        }
1237        Ok(())
1238    }
1239    pub async fn stop(&self) {
1240        // Stop intake/tickers first, then close per-session queues so workers
1241        // can flush pending buffered messages and exit gracefully.
1242        self.shutdown.cancel();
1243        self.session_txs.clear();
1244        let mut tasks = self.tasks.lock().await;
1245        let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
1246        loop {
1247            tokio::select! {
1248                result = tasks.join_next() => {
1249                    if result.is_none() { break; }
1250                }
1251                _ = sleep_until(deadline) => {
1252                    tasks.abort_all();
1253                    break;
1254                }
1255            }
1256        }
1257    }
1258}
1259/// Per-session idle TTL: after this long with no incoming message, the
1260/// debounce task exits and is removed from `session_txs`. Prevents the
1261/// per-agent map from growing unbounded when traffic churns through
1262/// many short-lived sessions (every chat gets its own session_id).
1263const SESSION_IDLE_TTL: Duration = Duration::from_secs(600);
1264#[allow(clippy::too_many_arguments)]
1265async fn session_debounce_task(
1266    mut rx: mpsc::Receiver<InboundMessage>,
1267    behavior: Arc<dyn AgentBehavior>,
1268    ctx: AgentContext,
1269    debounce_ms: Duration,
1270    shutdown: CancellationToken,
1271    session_id: Uuid,
1272    session_txs: Arc<DashMap<Uuid, mpsc::Sender<InboundMessage>>>,
1273    my_tx: mpsc::Sender<InboundMessage>,
1274) {
1275    let mut buffer: Vec<InboundMessage> = Vec::new();
1276    let mut deadline: Option<Instant> = None;
1277    let mut wake: Option<ScheduledWake> = None;
1278    // Rolling idle deadline: reset on every recv, fire when reached.
1279    let mut idle_deadline = Instant::now() + SESSION_IDLE_TTL;
1280    let mut tick_budget = TickBudgetWindow::new();
1281    loop {
1282        tokio::select! {
1283            biased;
1284            _ = shutdown.cancelled() => {
1285                // Drain what is already queued and flush before stopping.
1286                while let Ok(m) = rx.try_recv() {
1287                    buffer.push(m);
1288                }
1289                if !buffer.is_empty() {
1290                    let _ = flush(
1291                        &behavior,
1292                        &ctx,
1293                        mem::take(&mut buffer),
1294                        &mut rx,
1295                        None,
1296                    )
1297                    .await;
1298                }
1299                break;
1300            },
1301            msg = rx.recv() => {
1302                match msg {
1303                    Some(m) => {
1304                        if let Some(cancelled) = wake.take() {
1305                            inc_proactive_event(&ctx.agent_id, "sleep.interrupted");
1306                            tracing::info!(
1307                                agent_id = %ctx.agent_id,
1308                                %session_id,
1309                                reason = %cancelled.reason,
1310                                "proactive sleep interrupted by inbound message"
1311                            );
1312                        }
1313                        let is_now = matches!(m.priority, MessagePriority::Now);
1314                        buffer.push(m);
1315                        idle_deadline = Instant::now() + SESSION_IDLE_TTL;
1316                        if debounce_ms.is_zero() || is_now {
1317                            // flush immediately — no timer needed
1318                            if let Some(control) =
1319                                flush(
1320                                    &behavior,
1321                                    &ctx,
1322                                    mem::take(&mut buffer),
1323                                    &mut rx,
1324                                    Some(&shutdown),
1325                                )
1326                                .await
1327                            {
1328                                apply_turn_control(control, &ctx, session_id, &mut wake, &mut idle_deadline);
1329                            }
1330                            deadline = None;
1331                        } else {
1332                            deadline = Some(Instant::now() + debounce_ms);
1333                        }
1334                    }
1335                    None => {
1336                        // sender dropped — flush remaining
1337                        if !buffer.is_empty() {
1338                            let _ = flush(
1339                                &behavior,
1340                                &ctx,
1341                                mem::take(&mut buffer),
1342                                &mut rx,
1343                                Some(&shutdown),
1344                            )
1345                            .await;
1346                        }
1347                        break;
1348                    }
1349                }
1350            }
1351            _ = async {
1352                match deadline {
1353                    Some(d) => sleep_until(d).await,
1354                    None => std::future::pending().await,
1355                }
1356            } => {
1357                let items = mem::take(&mut buffer);
1358                deadline = None;
1359                if let Some(control) = flush(&behavior, &ctx, items, &mut rx, Some(&shutdown)).await
1360                {
1361                    apply_turn_control(control, &ctx, session_id, &mut wake, &mut idle_deadline);
1362                }
1363            }
1364            _ = async {
1365                match wake.as_ref() {
1366                    Some(w) => {
1367                        sleep_until(
1368                            (w.sleep_started_at + Duration::from_millis(w.duration_ms)).into(),
1369                        )
1370                        .await
1371                    }
1372                    None => std::future::pending().await,
1373                }
1374            } => {
1375                if let Some(fired) = wake.take() {
1376                    let daily_turn_budget = ctx
1377                        .effective
1378                        .as_ref()
1379                        .map(|p| p.proactive.daily_turn_budget)
1380                        .unwrap_or(0);
1381                    if !tick_budget.try_consume(daily_turn_budget) {
1382                        let retry_ms = ctx
1383                            .effective
1384                            .as_ref()
1385                            .map(|p| p.proactive.effective_tick_interval_secs().saturating_mul(1_000))
1386                            .unwrap_or(600_000);
1387                        tracing::warn!(
1388                            agent_id = %ctx.agent_id,
1389                            %session_id,
1390                            daily_turn_budget,
1391                            "proactive tick suppressed: daily budget exhausted"
1392                        );
1393                        wake = Some(ScheduledWake {
1394                            duration_ms: retry_ms.max(60_000),
1395                            reason: "daily_turn_budget_exhausted".to_string(),
1396                            sleep_started_at: std::time::Instant::now(),
1397                        });
1398                        continue;
1399                    }
1400                    inc_proactive_event(&ctx.agent_id, "tick.fired");
1401                    let elapsed_ms = fired.sleep_started_at.elapsed().as_millis() as u64;
1402                    let tick_text = build_tick_prompt(&fired, elapsed_ms);
1403                    let mut tick = InboundMessage::new(session_id, &ctx.agent_id, tick_text);
1404                    tick.trigger = RunTrigger::Tick;
1405                    tick.source_plugin = "proactive".to_string();
1406                    tick.sender_id = None;
1407                    tick.priority = MessagePriority::Later;
1408                    // Phase 82.5 — proactive ticks are
1409                    // system-injected turns. Microapps branch on
1410                    // `ctx.inbound().kind == InternalSystem` to
1411                    // skip per-user rate-limits and avoid
1412                    // anti-loop heuristics that only apply to
1413                    // external traffic.
1414                    tick.inbound = Some(
1415                        nexo_tool_meta::InboundMessageMeta::internal_system()
1416                            .with_ts(chrono::Utc::now()),
1417                    );
1418                    tracing::info!(
1419                        agent_id = %ctx.agent_id,
1420                        %session_id,
1421                        elapsed_ms,
1422                        reason = %fired.reason,
1423                        "proactive sleep fired; injecting tick"
1424                    );
1425                    if !buffer.is_empty() {
1426                        let _ = flush(
1427                            &behavior,
1428                            &ctx,
1429                            mem::take(&mut buffer),
1430                            &mut rx,
1431                            Some(&shutdown),
1432                        )
1433                        .await;
1434                    }
1435                    if let Some(control) =
1436                        flush(&behavior, &ctx, vec![tick], &mut rx, Some(&shutdown)).await
1437                    {
1438                        apply_turn_control(
1439                            control,
1440                            &ctx,
1441                            session_id,
1442                            &mut wake,
1443                            &mut idle_deadline,
1444                        );
1445                    }
1446                }
1447            }
1448            _ = sleep_until(idle_deadline) => {
1449                // No activity for `SESSION_IDLE_TTL`. Exit so the
1450                // task doesn't linger indefinitely. The session_txs
1451                // cleanup below removes our entry; a future message
1452                // on this session respawns a fresh task.
1453                tracing::debug!(
1454                    %session_id,
1455                    ttl_secs = SESSION_IDLE_TTL.as_secs(),
1456                    "session debounce task idle — exiting"
1457                );
1458                break;
1459            }
1460        }
1461    }
1462    // Cleanup: remove our entry so the DashMap doesn't accumulate dead
1463    // sessions. Use `remove_if` with `same_channel` to avoid the race
1464    // where a fresh message raced in after we decided to exit — in
1465    // that case `or_insert_with` already replaced us, and we must not
1466    // evict the newcomer's sender.
1467    session_txs.remove_if(&session_id, |_, current_tx| current_tx.same_channel(&my_tx));
1468}
1469fn apply_turn_control(
1470    control: AgentTurnControl,
1471    ctx: &AgentContext,
1472    session_id: Uuid,
1473    wake: &mut Option<ScheduledWake>,
1474    idle_deadline: &mut Instant,
1475) {
1476    match control {
1477        AgentTurnControl::Done => {}
1478        AgentTurnControl::Sleep {
1479            duration_ms,
1480            reason,
1481        } => {
1482            let sleep_started_at = std::time::Instant::now();
1483            let max_idle_secs = ctx
1484                .effective
1485                .as_ref()
1486                .map(|p| p.proactive.max_idle_secs)
1487                .unwrap_or(86_400);
1488            let idle_ms = max_idle_secs
1489                .saturating_mul(1_000)
1490                .max(duration_ms.saturating_add(60_000));
1491            *idle_deadline = Instant::now() + Duration::from_millis(idle_ms);
1492            *wake = Some(ScheduledWake {
1493                duration_ms,
1494                reason,
1495                sleep_started_at,
1496            });
1497            inc_proactive_event(&ctx.agent_id, "sleep.entered");
1498            if let Some(w) = wake.as_ref() {
1499                tracing::info!(
1500                    agent_id = %ctx.agent_id,
1501                    %session_id,
1502                    duration_ms = w.duration_ms,
1503                    reason = %w.reason,
1504                    "proactive sleep scheduled"
1505                );
1506            }
1507        }
1508    }
1509}
1510
1511struct TickBudgetWindow {
1512    started_at: std::time::Instant,
1513    used: u32,
1514}
1515
1516impl TickBudgetWindow {
1517    fn new() -> Self {
1518        Self {
1519            started_at: std::time::Instant::now(),
1520            used: 0,
1521        }
1522    }
1523
1524    fn try_consume(&mut self, budget: u32) -> bool {
1525        if self.started_at.elapsed() >= Duration::from_secs(86_400) {
1526            self.started_at = std::time::Instant::now();
1527            self.used = 0;
1528        }
1529        if budget == 0 {
1530            return true;
1531        }
1532        if self.used >= budget {
1533            return false;
1534        }
1535        self.used = self.used.saturating_add(1);
1536        true
1537    }
1538}
1539
1540async fn flush(
1541    behavior: &Arc<dyn AgentBehavior>,
1542    ctx: &AgentContext,
1543    items: Vec<InboundMessage>,
1544    rx: &mut mpsc::Receiver<InboundMessage>,
1545    shutdown: Option<&CancellationToken>,
1546) -> Option<AgentTurnControl> {
1547    let mut queue: VecDeque<InboundMessage> = items.into();
1548    queue.make_contiguous().sort_by_key(|m| m.priority.rank());
1549
1550    let mut last_control = None;
1551    while let Some(msg) = queue.pop_front() {
1552        let source_plugin = msg.source_plugin.clone();
1553        let source_instance = msg.source_instance.clone();
1554        let sender_id = msg.sender_id.clone();
1555        let mut turn_ctx = ctx.clone().with_sender_trusted(msg.sender_trusted);
1556        if matches!(msg.trigger, RunTrigger::User) && !source_plugin.is_empty() {
1557            turn_ctx = turn_ctx.with_inbound_origin(
1558                source_plugin.clone(),
1559                source_instance
1560                    .clone()
1561                    .unwrap_or_else(|| "default".to_string()),
1562                sender_id.clone().unwrap_or_default(),
1563            );
1564        }
1565        // Phase 82.5 — layer the per-turn inbound meta built at the
1566        // intake site so `AgentContext::build_meta_value` (called by
1567        // extension_tool / mcp_tool) stamps `_meta.nexo.inbound` on
1568        // outgoing tool calls with the *current* turn's data, not
1569        // the session's first turn.
1570        if let Some(ref imeta) = msg.inbound {
1571            turn_ctx = turn_ctx.with_inbound_meta(imeta.clone());
1572        }
1573        inc_messages_processed_total(&ctx.agent_id);
1574        let span = tracing::info_span!(
1575            "agent.message",
1576            agent_id = %ctx.agent_id,
1577            session_id = %msg.session_id,
1578            message_id = %msg.id,
1579            trigger = ?msg.trigger,
1580            priority = %msg.priority.as_str(),
1581            source_plugin = %source_plugin
1582        );
1583        // Capture a snapshot of the message before we move it so that
1584        // a handler panic / error path can DLQ it without losing data.
1585        let dlq_payload = serde_json::json!({
1586            "agent_id": ctx.agent_id,
1587            "session_id": msg.session_id,
1588            "message_id": msg.id,
1589            "text": msg.text,
1590            "source_plugin": source_plugin,
1591            "source_instance": source_instance,
1592            "sender_id": sender_id,
1593            "priority": msg.priority.as_str(),
1594        });
1595        let call = behavior.on_message_control(&turn_ctx, msg).instrument(span);
1596        tokio::pin!(call);
1597        let mut interrupted_by_now = false;
1598        let mut call_result: Option<anyhow::Result<AgentTurnControl>> = None;
1599
1600        loop {
1601            tokio::select! {
1602                biased;
1603                _ = async {
1604                    match shutdown {
1605                        Some(tok) => tok.cancelled().await,
1606                        None => std::future::pending::<()>().await,
1607                    }
1608                } => {
1609                    return last_control;
1610                }
1611                incoming = rx.recv() => {
1612                    let Some(incoming) = incoming else {
1613                        continue;
1614                    };
1615                    if matches!(incoming.priority, MessagePriority::Now) {
1616                        tracing::info!(
1617                            agent_id = %ctx.agent_id,
1618                            session_id = %incoming.session_id,
1619                            message_id = %incoming.id,
1620                            "priority=now received; interrupting in-flight turn"
1621                        );
1622                        push_by_priority(&mut queue, incoming);
1623                        interrupted_by_now = true;
1624                        break;
1625                    }
1626                    push_by_priority(&mut queue, incoming);
1627                }
1628                res = &mut call => {
1629                    call_result = Some(res);
1630                    break;
1631                }
1632            }
1633        }
1634
1635        if interrupted_by_now {
1636            continue;
1637        }
1638
1639        match call_result.expect("call must resolve unless interrupted/cancelled") {
1640            Ok(control) => {
1641                last_control = Some(control);
1642            }
1643            Err(e) => {
1644                tracing::error!(
1645                    agent_id = %turn_ctx.agent_id,
1646                    error = %e,
1647                    "on_message failed — publishing to DLQ topic for ops review"
1648                );
1649                // Best-effort DLQ: publish to a well-known topic so ops
1650                // can attach alerting / retry tooling. Never blocks the
1651                // loop — a broker hiccup here is logged and we move on.
1652                let dlq_topic = format!("agent.dlq.{}", turn_ctx.agent_id);
1653                let mut ev = nexo_broker::Event::new(
1654                    &dlq_topic,
1655                    &turn_ctx.agent_id,
1656                    serde_json::json!({
1657                        "error": e.to_string(),
1658                        "message": dlq_payload,
1659                    }),
1660                );
1661                ev.session_id = dlq_payload
1662                    .get("session_id")
1663                    .and_then(|v| v.as_str())
1664                    .and_then(|s| Uuid::parse_str(s).ok());
1665                if let Err(pe) = turn_ctx.broker.publish(&dlq_topic, ev).await {
1666                    tracing::warn!(
1667                        agent_id = %turn_ctx.agent_id,
1668                        error = %pe,
1669                        "DLQ publish failed — message unrecoverable"
1670                    );
1671                }
1672            }
1673        }
1674    }
1675    last_control
1676}
1677
1678fn push_by_priority(queue: &mut VecDeque<InboundMessage>, msg: InboundMessage) {
1679    let rank = msg.priority.rank();
1680    let idx = queue
1681        .iter()
1682        .position(|m| m.priority.rank() > rank)
1683        .unwrap_or(queue.len());
1684    queue.insert(idx, msg);
1685}
1686fn parse_session_id_from_context(context: &Value) -> Option<Uuid> {
1687    context
1688        .get("session_id")
1689        .and_then(|v| v.as_str())
1690        .and_then(|s| Uuid::parse_str(s).ok())
1691}
1692/// Phase 82.5 — build an [`InboundMessageMeta`] from a raw plugin
1693/// payload. Provider-agnostic: works for any inbound shape that
1694/// exposes the standard fields (`from`, `msg_id`, `timestamp`,
1695/// optional `reply_to`).
1696///
1697/// Returns `None` when the payload carries neither `msg_id` nor
1698/// `from` (e.g. status events, typing notifications). The caller
1699/// already gates LLM dispatch on text/media presence so a `None`
1700/// here just means "no meta to stamp" — the turn proceeds without
1701/// inbound bucket on `_meta.nexo.inbound`.
1702///
1703/// `has_media` is sourced from the caller (after
1704/// `extract_inbound_media`) rather than re-derived here so the two
1705/// helpers stay independent.
1706fn extract_inbound_meta(
1707    payload: &Value,
1708    sender_id: Option<&str>,
1709    has_media: bool,
1710) -> Option<InboundMessageMeta> {
1711    let msg_id = payload.get("msg_id").and_then(|v| v.as_str());
1712    if sender_id.is_none() && msg_id.is_none() {
1713        return None;
1714    }
1715    let mut meta = match (sender_id, msg_id) {
1716        (Some(s), Some(m)) => InboundMessageMeta::external_user(s, m),
1717        (Some(s), None) => {
1718            // Synthesise a stable msg_id so dedupe / idempotency
1719            // consumers always have a non-empty key. Uses the
1720            // sender + a uuid v4 to avoid collisions across users.
1721            let synth = format!("synth.{}.{}", s, Uuid::new_v4());
1722            InboundMessageMeta::external_user(s, synth)
1723        }
1724        (None, Some(m)) => {
1725            let mut m_meta = InboundMessageMeta::external_user("anonymous", m);
1726            m_meta.sender_id = None;
1727            m_meta
1728        }
1729        (None, None) => unreachable!(),
1730    };
1731    // Phase 82.5 — honor payload-supplied `inbound_kind` when
1732    // present. Event-subscriber synthesizer stamps it from the
1733    // operator-declared yaml field so `cron.daily` etc. surface
1734    // as `internal_system`. Unknown / missing falls back to
1735    // `external_user` already set by the constructors above.
1736    if let Some(k) = payload.get("inbound_kind").and_then(|v| v.as_str()) {
1737        match k {
1738            "external_user" => meta.kind = nexo_tool_meta::InboundKind::ExternalUser,
1739            "internal_system" => {
1740                meta.kind = nexo_tool_meta::InboundKind::InternalSystem;
1741                // Internal-system turns have no real sender — clear
1742                // any synthesised sender from the constructor above
1743                // so consumers don't rate-limit by it.
1744                meta.sender_id = None;
1745            }
1746            "inter_session" => meta.kind = nexo_tool_meta::InboundKind::InterSession,
1747            _ => {
1748                tracing::warn!(
1749                    inbound_kind = k,
1750                    "unknown payload-supplied inbound_kind; falling back to external_user",
1751                );
1752            }
1753        }
1754    }
1755    // Provider-supplied epoch seconds (whatsapp / future channels convention).
1756    if let Some(ts_secs) = payload.get("timestamp").and_then(|v| v.as_i64()) {
1757        if let Some(dt) = chrono::DateTime::<chrono::Utc>::from_timestamp(ts_secs, 0) {
1758            meta = meta.with_ts(dt);
1759        }
1760    }
1761    if let Some(reply) = payload.get("reply_to").and_then(|v| v.as_str()) {
1762        if !reply.is_empty() {
1763            meta = meta.with_reply_to(reply);
1764        }
1765    }
1766    if has_media {
1767        meta = meta.with_media();
1768    }
1769    Some(meta)
1770}
1771
1772/// Pull a media reference from an inbound plugin payload. Plugins flatten
1773/// `media_kind` + `media_path` at the top level (see telegram's
1774/// `InboundEvent::to_payload`) so this helper is wire-format agnostic.
1775fn extract_inbound_media(payload: &Value) -> Option<InboundMedia> {
1776    let kind = payload
1777        .get("media_kind")
1778        .and_then(|v| v.as_str())?
1779        .to_string();
1780    let path = payload
1781        .get("media_path")
1782        .and_then(|v| v.as_str())?
1783        .to_string();
1784    let mime_type = payload
1785        .pointer("/media/mime_type")
1786        .or_else(|| payload.pointer("/media/0/mime_type"))
1787        .and_then(|v| v.as_str())
1788        .map(|s| s.to_string());
1789    Some(InboundMedia {
1790        kind,
1791        path,
1792        mime_type,
1793    })
1794}
1795
1796/// Parse optional payload priority (`now` | `next` | `later`).
1797/// Unknown / missing values fall back to `next`.
1798fn parse_inbound_priority(payload: &Value) -> MessagePriority {
1799    let Some(raw) = payload.get("priority").and_then(|v| v.as_str()) else {
1800        return MessagePriority::Next;
1801    };
1802    if raw.eq_ignore_ascii_case("now") {
1803        MessagePriority::Now
1804    } else if raw.eq_ignore_ascii_case("later") {
1805        MessagePriority::Later
1806    } else {
1807        MessagePriority::Next
1808    }
1809}
1810/// Split `plugin.inbound.<plugin>[.<instance>]` into its parts.
1811/// Returns `("", None)` if the topic doesn't have the expected prefix
1812/// — caller's binding check treats that as "unknown source", which
1813/// only passes the filter when bindings are empty.
1814fn parse_inbound_topic(topic: &str) -> (String, Option<String>) {
1815    let Some(rest) = topic.strip_prefix("plugin.inbound.") else {
1816        return (String::new(), None);
1817    };
1818    match rest.split_once('.') {
1819        Some((plugin, instance)) if !instance.is_empty() => {
1820            (plugin.to_string(), Some(instance.to_string()))
1821        }
1822        _ => (rest.to_string(), None),
1823    }
1824}
1825/// Find the first binding index that matches `(plugin, instance)`.
1826/// Bindings and topics must agree on the `instance` axis: a binding
1827/// with `instance: None` only catches no-instance events, and an
1828/// `instance: Some(x)` binding only catches events with that exact
1829/// instance suffix. Used by the runtime inbound-subscriber loop to
1830/// both accept/reject events and select which binding's overrides
1831/// govern the session.
1832///
1833/// Returns `None` when no binding matches. Note: when an agent has no
1834/// bindings at all the caller interprets that as the legacy wildcard
1835/// ("accept every inbound"); this helper only speaks to the populated
1836/// case.
1837///
1838/// Earlier versions allowed `instance: None` bindings to match any
1839/// instance of their plugin. That made multi-bot setups silently
1840/// fan-out a single bot's messages to every agent that listed the
1841/// channel — `allow_agents` is enforced on outbound credentials, not
1842/// on inbound dispatch. Tightening the match closes that gap; the
1843/// migration story is "give every multi-bot binding an explicit
1844/// `instance` (matching the plugin's `instance:` field)".
1845fn match_binding_index(
1846    bindings: &[InboundBinding],
1847    plugin: &str,
1848    instance: Option<&str>,
1849) -> Option<usize> {
1850    bindings.iter().position(|b| {
1851        if b.plugin != plugin {
1852            return false;
1853        }
1854        match (b.instance.as_deref(), instance) {
1855            (None, None) => true,
1856            (Some(want), Some(got)) => want == got,
1857            _ => false,
1858        }
1859    })
1860}
1861
1862/// Back-compat boolean wrapper around [`match_binding_index`]. Kept for
1863/// the unit tests that assert the accept/reject semantics; production
1864/// callers use `match_binding_index` so the index can be fed into
1865/// `EffectiveBindingPolicy::resolve`.
1866#[cfg(test)]
1867fn binding_matches(bindings: &[InboundBinding], plugin: &str, instance: Option<&str>) -> bool {
1868    match_binding_index(bindings, plugin, instance).is_some()
1869}
1870
1871/// Phase 26.x — deliver the pairing challenge to the sender. When a
1872/// channel adapter is registered we use it for both sender-id
1873/// normalisation and channel-correct formatting (e.g. Telegram
1874/// MarkdownV2). For unregistered channels we fall back to the legacy
1875/// hardcoded broker publish so the operator still gets a log line and
1876/// the challenge text on `plugin.outbound.{whatsapp,telegram}`.
1877async fn deliver_pairing_challenge(
1878    broker: &AnyBroker,
1879    adapter: Option<&dyn nexo_pairing::PairingChannelAdapter>,
1880    channel: &str,
1881    instance: Option<&str>,
1882    account: &str,
1883    sender: &str,
1884    code: &str,
1885) {
1886    if let Some(adapter) = adapter {
1887        let to = adapter
1888            .normalize_sender(sender)
1889            .unwrap_or_else(|| sender.to_string());
1890        let text = adapter.format_challenge_text(code);
1891        match adapter.send_reply(account, &to, &text).await {
1892            Ok(()) => {
1893                crate::telemetry::inc_pairing_inbound_challenged(channel, "delivered_via_adapter")
1894            }
1895            Err(e) => {
1896                tracing::warn!(error = %e, %channel, "pairing adapter send_reply failed");
1897                crate::telemetry::inc_pairing_inbound_challenged(channel, "publish_failed");
1898            }
1899        }
1900        return;
1901    }
1902
1903    // Fallback: legacy hardcoded broker publish for channels with no
1904    // registered adapter. Mirrors the pre-26.x payload shape so any
1905    // existing dispatcher still recognises the message.
1906    let topic_base = match channel {
1907        "whatsapp" => "plugin.outbound.whatsapp",
1908        "telegram" => "plugin.outbound.telegram",
1909        _ => {
1910            crate::telemetry::inc_pairing_inbound_challenged(channel, "no_adapter_no_broker_topic");
1911            return;
1912        }
1913    };
1914    let topic = match instance {
1915        Some(inst) if !inst.is_empty() => format!("{topic_base}.{inst}"),
1916        _ => topic_base.to_string(),
1917    };
1918    let text =
1919        format!("🔐 Pairing required.\nAsk the operator to run:\n  nexo pair approve {code}",);
1920    let payload = serde_json::json!({
1921        "kind": "text",
1922        "to": sender,
1923        "text": text,
1924    });
1925    let evt = nexo_broker::Event::new(&topic, "core.pairing", payload);
1926    match broker.publish(&topic, evt).await {
1927        Ok(_) => {
1928            crate::telemetry::inc_pairing_inbound_challenged(channel, "delivered_via_broker");
1929        }
1930        Err(e) => {
1931            tracing::warn!(error = %e, %topic, "pairing challenge outbound publish failed");
1932            crate::telemetry::inc_pairing_inbound_challenged(channel, "publish_failed");
1933        }
1934    }
1935}
1936#[cfg(test)]
1937mod tests {
1938    use super::*;
1939    #[test]
1940    fn parse_topic_extracts_plugin_and_optional_instance() {
1941        assert_eq!(
1942            parse_inbound_topic("plugin.inbound.telegram"),
1943            ("telegram".into(), None)
1944        );
1945        assert_eq!(
1946            parse_inbound_topic("plugin.inbound.telegram.sales"),
1947            ("telegram".into(), Some("sales".into()))
1948        );
1949        // Nested instances collapse — everything after the 2nd dot is
1950        // treated as the instance name so bot_names can contain `.`.
1951        assert_eq!(
1952            parse_inbound_topic("plugin.inbound.telegram.bot.v2"),
1953            ("telegram".into(), Some("bot.v2".into()))
1954        );
1955        // Non-inbound topics → neutral sentinel; binding filter rejects
1956        // them unless bindings are empty.
1957        assert_eq!(parse_inbound_topic("something.else"), (String::new(), None));
1958        assert_eq!(
1959            parse_inbound_topic("plugin.inbound."),
1960            (String::new(), None)
1961        );
1962    }
1963
1964    #[test]
1965    fn extract_inbound_meta_from_text_message_populates_sender_msg_ts() {
1966        let payload = serde_json::json!({
1967            "kind": "message",
1968            "from": "+5491100",
1969            "msg_id": "wa.ABCD1234",
1970            "timestamp": 1_756_700_096_i64,
1971        });
1972        let meta =
1973            extract_inbound_meta(&payload, Some("+5491100"), false).expect("meta should build");
1974        assert_eq!(meta.kind, nexo_tool_meta::InboundKind::ExternalUser);
1975        assert_eq!(meta.sender_id.as_deref(), Some("+5491100"));
1976        assert_eq!(meta.msg_id.as_deref(), Some("wa.ABCD1234"));
1977        assert!(meta.inbound_ts.is_some());
1978        assert!(!meta.has_media);
1979        assert!(meta.reply_to_msg_id.is_none());
1980    }
1981
1982    #[test]
1983    fn extract_inbound_meta_with_reply_and_media_layers_correctly() {
1984        let payload = serde_json::json!({
1985            "kind": "message",
1986            "from": "+5491100",
1987            "msg_id": "wa.ABCD",
1988            "reply_to": "wa.PREV0001",
1989            "timestamp": 1_756_700_096_i64,
1990        });
1991        let meta =
1992            extract_inbound_meta(&payload, Some("+5491100"), true).expect("meta should build");
1993        assert!(meta.has_media);
1994        assert_eq!(meta.reply_to_msg_id.as_deref(), Some("wa.PREV0001"));
1995    }
1996
1997    #[test]
1998    fn extract_inbound_meta_returns_none_when_neither_sender_nor_msg_id() {
1999        let payload = serde_json::json!({"kind": "status"});
2000        assert!(extract_inbound_meta(&payload, None, false).is_none());
2001    }
2002
2003    #[test]
2004    fn extract_inbound_meta_synthesises_msg_id_when_absent_but_sender_present() {
2005        // Status events / reactions sometimes lack msg_id but carry
2006        // sender — synthesise a stable id so dedupe consumers always
2007        // have a key.
2008        let payload = serde_json::json!({"from": "+5491100"});
2009        let meta =
2010            extract_inbound_meta(&payload, Some("+5491100"), false).expect("meta should build");
2011        assert!(meta
2012            .msg_id
2013            .as_deref()
2014            .unwrap()
2015            .starts_with("synth.+5491100."));
2016    }
2017
2018    #[test]
2019    fn extract_inbound_meta_honors_payload_supplied_internal_system_kind() {
2020        // Event-subscriber binding declared `inbound_kind: internal_system`
2021        // in YAML; synthesizer stamps it on payload. Helper must
2022        // reflect it on InboundMessageMeta.kind and clear sender_id
2023        // (system turns have no real sender).
2024        let payload = serde_json::json!({
2025            "kind": "message",
2026            "from": "cron.daily",
2027            "msg_id": "evt.123",
2028            "inbound_kind": "internal_system",
2029            "timestamp": 1_756_700_096_i64,
2030        });
2031        let meta =
2032            extract_inbound_meta(&payload, Some("cron.daily"), false).expect("meta should build");
2033        assert_eq!(meta.kind, nexo_tool_meta::InboundKind::InternalSystem);
2034        assert!(meta.sender_id.is_none(), "internal_system clears sender_id");
2035        assert_eq!(meta.msg_id.as_deref(), Some("evt.123"));
2036    }
2037
2038    #[test]
2039    fn delegation_inter_session_meta_carries_correlation_as_origin_session() {
2040        // Synthesise the wiring path for delegation receive: the
2041        // call site in `runtime::start` builds an InboundMessage,
2042        // sets `RunTrigger::Manual`, then stamps
2043        // `InboundMessageMeta::inter_session(correlation_id)`.
2044        // Validates the contract directly so a refactor that
2045        // forgets to layer the meta surfaces here.
2046        let correlation_id = Uuid::from_u128(0xDEADBEEF);
2047        let mut msg = InboundMessage::new(Uuid::new_v4(), "ana", "delegated task");
2048        msg.trigger = RunTrigger::Manual;
2049        msg.source_plugin = "agent".to_string();
2050        msg.inbound = Some(
2051            nexo_tool_meta::InboundMessageMeta::inter_session(correlation_id)
2052                .with_ts(chrono::Utc::now()),
2053        );
2054        let imeta = msg.inbound.as_ref().unwrap();
2055        assert_eq!(imeta.kind, nexo_tool_meta::InboundKind::InterSession);
2056        assert_eq!(imeta.origin_session_id, Some(correlation_id));
2057        assert!(imeta.sender_id.is_none(), "inter_session has no sender");
2058    }
2059
2060    #[test]
2061    fn proactive_tick_internal_system_meta_clears_sender_and_msg() {
2062        // Scheduler-driven turn: kind=InternalSystem, no sender,
2063        // no msg_id (synthesised internally). Validates the shape
2064        // a microapp sees on `ctx.inbound()` for proactive turns.
2065        let mut tick = InboundMessage::new(Uuid::new_v4(), "ana", "tick body");
2066        tick.trigger = RunTrigger::Tick;
2067        tick.source_plugin = "proactive".to_string();
2068        tick.priority = MessagePriority::Later;
2069        tick.inbound =
2070            Some(nexo_tool_meta::InboundMessageMeta::internal_system().with_ts(chrono::Utc::now()));
2071        let imeta = tick.inbound.as_ref().unwrap();
2072        assert_eq!(imeta.kind, nexo_tool_meta::InboundKind::InternalSystem);
2073        assert!(imeta.sender_id.is_none());
2074        assert!(imeta.msg_id.is_none());
2075        assert!(imeta.origin_session_id.is_none());
2076        assert!(imeta.inbound_ts.is_some());
2077    }
2078
2079    #[test]
2080    fn extract_inbound_meta_unknown_payload_kind_falls_back_to_external_user() {
2081        let payload = serde_json::json!({
2082            "from": "x",
2083            "msg_id": "y",
2084            "inbound_kind": "future_kind_v3",
2085        });
2086        let meta = extract_inbound_meta(&payload, Some("x"), false).expect("meta should build");
2087        assert_eq!(meta.kind, nexo_tool_meta::InboundKind::ExternalUser);
2088    }
2089
2090    #[test]
2091    fn extract_inbound_meta_provider_agnostic_telegram_shape() {
2092        // Same shape works for telegram (future) — proves the helper
2093        // is not whatsapp-specific.
2094        let payload = serde_json::json!({
2095            "from": "tg.user_42",
2096            "msg_id": "tg.msg.7",
2097            "timestamp": 1_756_700_096_i64,
2098        });
2099        let meta =
2100            extract_inbound_meta(&payload, Some("tg.user_42"), false).expect("meta should build");
2101        assert_eq!(meta.sender_id.as_deref(), Some("tg.user_42"));
2102        assert_eq!(meta.msg_id.as_deref(), Some("tg.msg.7"));
2103    }
2104
2105    #[test]
2106    fn parse_inbound_priority_accepts_known_values_and_defaults() {
2107        assert_eq!(
2108            parse_inbound_priority(&serde_json::json!({"priority":"now"})),
2109            MessagePriority::Now
2110        );
2111        assert_eq!(
2112            parse_inbound_priority(&serde_json::json!({"priority":"NEXT"})),
2113            MessagePriority::Next
2114        );
2115        assert_eq!(
2116            parse_inbound_priority(&serde_json::json!({"priority":"later"})),
2117            MessagePriority::Later
2118        );
2119        // Unknown values fail-safe to `next`.
2120        assert_eq!(
2121            parse_inbound_priority(&serde_json::json!({"priority":"whatever"})),
2122            MessagePriority::Next
2123        );
2124        assert_eq!(
2125            parse_inbound_priority(&serde_json::json!({})),
2126            MessagePriority::Next
2127        );
2128    }
2129    #[test]
2130    fn match_binding_index_returns_first_winner_for_overlapping_rules() {
2131        // Bindings only match topics that share their instance axis —
2132        // a no-instance binding catches no-instance topics, a
2133        // `Some("sales")` binding catches the `.sales` suffix. Earlier
2134        // versions let `instance: None` swallow every instance, which
2135        // silently fanned a single bot's messages out to every agent
2136        // (fixed: see "Telegram inbound fan-out ignores allow_agents"
2137        // follow-up).
2138        let bindings = vec![
2139            InboundBinding {
2140                plugin: "telegram".into(),
2141                instance: None,
2142                ..Default::default()
2143            },
2144            InboundBinding {
2145                plugin: "telegram".into(),
2146                instance: Some("sales".into()),
2147                ..Default::default()
2148            },
2149        ];
2150        // Specific topic only the specific binding catches; the
2151        // no-instance binding does NOT swallow it.
2152        assert_eq!(
2153            match_binding_index(&bindings, "telegram", Some("sales")),
2154            Some(1),
2155            "specific instance binding (idx 1) wins; no-instance binding ignores `.sales`"
2156        );
2157        assert_eq!(
2158            match_binding_index(&bindings, "telegram", None),
2159            Some(0),
2160            "no-instance topic only the no-instance binding catches"
2161        );
2162        // No match → None.
2163        assert_eq!(match_binding_index(&bindings, "whatsapp", None), None);
2164    }
2165
2166    #[test]
2167    fn binding_matches_covers_plugin_wide_and_exact_instance() {
2168        let no_instance_only = vec![InboundBinding {
2169            plugin: "telegram".into(),
2170            instance: None,
2171            ..Default::default()
2172        }];
2173        assert!(binding_matches(&no_instance_only, "telegram", None));
2174        // Tightened semantics: a no-instance binding does NOT match
2175        // an instance-tagged topic anymore.
2176        assert!(!binding_matches(
2177            &no_instance_only,
2178            "telegram",
2179            Some("anyone")
2180        ));
2181        assert!(!binding_matches(&no_instance_only, "whatsapp", None));
2182        let only_sales = vec![InboundBinding {
2183            plugin: "telegram".into(),
2184            instance: Some("sales".into()),
2185            ..Default::default()
2186        }];
2187        assert!(binding_matches(&only_sales, "telegram", Some("sales")));
2188        assert!(!binding_matches(&only_sales, "telegram", Some("boss")));
2189        // Binding asked for a specific instance but the topic didn't
2190        // have one — strict no-match (avoids leaks from legacy topics).
2191        assert!(!binding_matches(&only_sales, "telegram", None));
2192        // Multiple bindings: OR-semantic.
2193        let mixed = vec![
2194            InboundBinding {
2195                plugin: "telegram".into(),
2196                instance: Some("sales".into()),
2197                ..Default::default()
2198            },
2199            InboundBinding {
2200                plugin: "whatsapp".into(),
2201                instance: None,
2202                ..Default::default()
2203            },
2204        ];
2205        assert!(binding_matches(&mixed, "telegram", Some("sales")));
2206        // Whatsapp binding has `instance: None` → only catches
2207        // no-instance topics under the new strict rule.
2208        assert!(binding_matches(&mixed, "whatsapp", None));
2209        assert!(!binding_matches(&mixed, "whatsapp", Some("whatever")));
2210        assert!(!binding_matches(&mixed, "telegram", Some("boss")));
2211    }
2212    #[test]
2213    fn same_channel_distinguishes_senders_for_cleanup_race() {
2214        // The on-exit cleanup uses Sender::same_channel to avoid
2215        // evicting a newer entry that raced in after we decided to
2216        // shut down. Verify the primitive actually distinguishes.
2217        use tokio::sync::mpsc;
2218        let (a_tx, _a_rx) = mpsc::channel::<i32>(1);
2219        let (b_tx, _b_rx) = mpsc::channel::<i32>(1);
2220        assert!(a_tx.same_channel(&a_tx.clone()));
2221        assert!(!a_tx.same_channel(&b_tx));
2222    }
2223}