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