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}