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