agentmux_srv\backend\reactive/
handler.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::collections::HashMap;
6use std::sync::{Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use super::sanitize::{format_injected_message, sanitize_message, validate_agent_id};
10use super::types::*;
11use super::{now_unix_millis, sha256_hex, AUDIT_LOG_MAX, RATE_LIMIT_MAX};
12
13// ---- Rate Limiter ----
14
15pub(super) struct RateLimiter {
16    tokens: u32,
17    max_tokens: u32,
18    last_refill: Instant,
19}
20
21impl RateLimiter {
22    pub(super) fn new(max_tokens: u32) -> Self {
23        Self {
24            tokens: max_tokens,
25            max_tokens,
26            last_refill: Instant::now(),
27        }
28    }
29
30    pub(super) fn check(&mut self) -> bool {
31        let now = Instant::now();
32        let elapsed = now.duration_since(self.last_refill);
33        if elapsed >= Duration::from_secs(1) {
34            self.tokens = self.max_tokens;
35            self.last_refill = now;
36        }
37        if self.tokens > 0 {
38            self.tokens -= 1;
39            true
40        } else {
41            false
42        }
43    }
44}
45
46// ---- Handler ----
47
48/// Core reactive messaging handler.
49///
50/// Manages agent registrations, rate limiting, message injection,
51/// and audit logging.
52pub struct Handler {
53    agent_to_block: HashMap<String, String>,
54    block_to_agent: HashMap<String, String>,
55    agent_info: HashMap<String, AgentRegistration>,
56    input_sender: Option<InputSender>,
57    /// Controller-aware delivery for non-PTY agents (persistent stream-json / ACP).
58    /// When set, it is tried before the PTY keystroke path so messages reach (and
59    /// steer) agents that have no terminal. See `set_message_sender`.
60    message_sender: Option<MessageSender>,
61    audit_log: Vec<AuditLogEntry>,
62    rate_limiter: RateLimiter,
63    include_source_in_message: bool,
64}
65
66impl Handler {
67    /// Create a new handler without an input sender.
68    /// Call `set_input_sender` before injecting messages.
69    pub fn new() -> Self {
70        Self {
71            agent_to_block: HashMap::new(),
72            block_to_agent: HashMap::new(),
73            agent_info: HashMap::new(),
74            input_sender: None,
75            message_sender: None,
76            audit_log: Vec::with_capacity(AUDIT_LOG_MAX),
77            rate_limiter: RateLimiter::new(RATE_LIMIT_MAX),
78            include_source_in_message: false,
79        }
80    }
81
82    /// Set the input sender function for message injection.
83    pub fn set_input_sender(&mut self, sender: InputSender) {
84        self.input_sender = Some(sender);
85    }
86
87    /// Set the controller-aware message sender. When present, `inject_message`
88    /// tries it first: persistent stream-json and ACP agents receive a structured
89    /// message on their live channel (mid-turn steering); PTY-based agents report
90    /// back so injection falls through to the keystroke path.
91    pub fn set_message_sender(&mut self, sender: MessageSender) {
92        self.message_sender = Some(sender);
93    }
94
95    /// Set whether to include source agent prefix in injected messages.
96    #[allow(dead_code)]
97    pub fn set_include_source(&mut self, include: bool) {
98        self.include_source_in_message = include;
99    }
100
101    /// Register an agent with a block.
102    pub fn register_agent(
103        &mut self,
104        agent_id: &str,
105        block_id: &str,
106        tab_id: Option<&str>,
107    ) -> Result<(), String> {
108        if !validate_agent_id(agent_id) {
109            return Err(format!("invalid agent ID: {}", agent_id));
110        }
111
112        let agent_key = agent_id.to_lowercase();
113
114        // Remove existing registration for this agent
115        if let Some(old_block) = self.agent_to_block.remove(&agent_key) {
116            self.block_to_agent.remove(&old_block);
117        }
118
119        // Remove existing registration for this block
120        if let Some(old_agent) = self.block_to_agent.remove(block_id) {
121            self.agent_to_block.remove(&old_agent);
122            self.agent_info.remove(&old_agent);
123        }
124
125        let now = now_unix_millis();
126        self.agent_to_block
127            .insert(agent_key.clone(), block_id.to_string());
128        self.block_to_agent
129            .insert(block_id.to_string(), agent_key.clone());
130        self.agent_info.insert(
131            agent_key.clone(),
132            AgentRegistration {
133                agent_id: agent_id.to_string(),
134                block_id: block_id.to_string(),
135                tab_id: tab_id.map(|s| s.to_string()),
136                registered_at: now,
137                last_seen: now,
138            },
139        );
140
141        Ok(())
142    }
143
144    /// Unregister an agent.
145    pub fn unregister_agent(&mut self, agent_id: &str) {
146        let agent_key = agent_id.to_lowercase();
147        if let Some(block_id) = self.agent_to_block.remove(&agent_key) {
148            self.block_to_agent.remove(&block_id);
149        }
150        self.agent_info.remove(&agent_key);
151    }
152
153    /// Unregister by block ID.
154    pub fn unregister_block(&mut self, block_id: &str) {
155        if let Some(agent_id) = self.block_to_agent.remove(block_id) {
156            self.agent_to_block.remove(&agent_id);
157            self.agent_info.remove(&agent_id);
158        }
159    }
160
161    /// Update the last_seen timestamp for an agent.
162    #[allow(dead_code)]
163    pub fn update_last_seen(&mut self, agent_id: &str) {
164        if let Some(info) = self.agent_info.get_mut(&agent_id.to_lowercase()) {
165            info.last_seen = now_unix_millis();
166        }
167    }
168
169    /// Get agent registration by agent ID.
170    pub fn get_agent(&self, agent_id: &str) -> Option<&AgentRegistration> {
171        self.agent_info.get(&agent_id.to_lowercase())
172    }
173
174    /// Get agent registration by block ID.
175    #[allow(dead_code)]
176    pub fn get_agent_by_block(&self, block_id: &str) -> Option<&AgentRegistration> {
177        self.block_to_agent
178            .get(block_id)
179            .and_then(|agent_id| self.agent_info.get(agent_id))
180    }
181
182    /// List all registered agents.
183    pub fn list_agents(&self) -> Vec<AgentRegistration> {
184        self.agent_info.values().cloned().collect()
185    }
186
187    /// List all block IDs that have a registered agent.
188    pub fn list_active_blocks(&self) -> Vec<String> {
189        self.block_to_agent.keys().cloned().collect()
190    }
191
192    /// Inject a message into an agent's terminal.
193    ///
194    /// Sends `message\r` as a single payload (required for text display),
195    /// then spawns 3 delayed `\r` sends at 200ms intervals as separate
196    /// PTY writes to ensure submission. See `specs/jekt-inject-timing.md`.
197    pub fn inject_message(&mut self, mut req: InjectionRequest) -> InjectionResponse {
198        let now = now_unix_millis();
199
200        // Generate request ID if missing
201        if req.request_id.is_none() || req.request_id.as_deref() == Some("") {
202            req.request_id = Some(uuid::Uuid::new_v4().to_string());
203        }
204        let request_id = req.request_id.clone().unwrap_or_default();
205
206        // Rate limit check
207        if !self.rate_limiter.check() {
208            return InjectionResponse {
209                success: false,
210                request_id,
211                block_id: None,
212                error: Some("rate limit exceeded".to_string()),
213                timestamp: now,
214            };
215        }
216
217        // Validate agent ID
218        if !validate_agent_id(&req.target_agent) {
219            return InjectionResponse {
220                success: false,
221                request_id,
222                block_id: None,
223                error: Some(format!("invalid agent ID: {}", req.target_agent)),
224                timestamp: now,
225            };
226        }
227
228        // Sanitize message
229        let sanitized = sanitize_message(&req.message);
230
231        // Look up block ID
232        let block_id = match self.agent_to_block.get(&req.target_agent.to_lowercase()) {
233            Some(id) => id.clone(),
234            None => {
235                let err = format!("agent not found: {}", req.target_agent);
236                self.log_audit(
237                    req.source_agent.as_deref(),
238                    &req.target_agent,
239                    "",
240                    &sanitized,
241                    false,
242                    Some(&err),
243                    &request_id,
244                );
245                return InjectionResponse {
246                    success: false,
247                    request_id,
248                    block_id: None,
249                    error: Some(err),
250                    timestamp: now,
251                };
252            }
253        };
254
255        // Format message with source prefix if configured
256        let final_msg = format_injected_message(
257            &sanitized,
258            req.source_agent.as_deref(),
259            self.include_source_in_message,
260        );
261
262        // Controller-aware delivery (SPEC_AGENT_CONTROL_PROTOCOL §6 / Phase 3).
263        // Persistent (stream-json) and ACP agents have no PTY — their inbox is a
264        // structured channel (live stdin NDJSON / `session/prompt`). Delivering there
265        // also lands the message mid-turn (steering) instead of waiting for idle.
266        // PTY-based shell/term agents report back so we fall through to keystrokes.
267        if let Some(ref deliver) = self.message_sender {
268            match deliver(&block_id, &final_msg) {
269                Ok(true) => {
270                    tracing::info!(
271                        target_agent = %req.target_agent,
272                        block_id = %block_id,
273                        "inject: structured delivery to non-PTY controller (mid-turn steer)"
274                    );
275                    self.log_audit(
276                        req.source_agent.as_deref(),
277                        &req.target_agent,
278                        &block_id,
279                        &sanitized,
280                        true,
281                        None,
282                        &request_id,
283                    );
284                    return InjectionResponse {
285                        success: true,
286                        request_id,
287                        block_id: Some(block_id),
288                        error: None,
289                        timestamp: now,
290                    };
291                }
292                Ok(false) => {
293                    // PTY-based controller — fall through to keystroke injection.
294                }
295                Err(e) => {
296                    // Structured controller but delivery failed (e.g. persistent
297                    // process not running). Do NOT fall back to PTY keystrokes — the
298                    // persistent controller rejects raw input. Surface the error.
299                    tracing::warn!(
300                        target_agent = %req.target_agent,
301                        block_id = %block_id,
302                        error = %e,
303                        "inject: structured delivery failed"
304                    );
305                    self.log_audit(
306                        req.source_agent.as_deref(),
307                        &req.target_agent,
308                        &block_id,
309                        &sanitized,
310                        false,
311                        Some(&e),
312                        &request_id,
313                    );
314                    return InjectionResponse {
315                        success: false,
316                        request_id,
317                        block_id: Some(block_id),
318                        error: Some(e),
319                        timestamp: now,
320                    };
321                }
322            }
323        }
324
325        // Send message via input sender
326        let sender = match &self.input_sender {
327            Some(s) => s.clone(),
328            None => {
329                let err = "input sender not configured".to_string();
330                self.log_audit(
331                    req.source_agent.as_deref(),
332                    &req.target_agent,
333                    &block_id,
334                    &sanitized,
335                    false,
336                    Some(&err),
337                    &request_id,
338                );
339                return InjectionResponse {
340                    success: false,
341                    request_id,
342                    block_id: Some(block_id),
343                    error: Some(err),
344                    timestamp: now,
345                };
346            }
347        };
348
349        // Jekt inject sequence (see specs/jekt-inject-timing.md):
350        // 1. \r to clear any partial input on the line
351        // 2. message\r as single payload (proven to display text — v0.31.122/125)
352        // 3. Three delayed \r at 200ms intervals as separate PTY writes to submit
353        let _ = sender(&block_id, b"\r");
354        let payload = format!("{}\r", final_msg);
355        tracing::info!(
356            target_agent = %req.target_agent,
357            block_id = %block_id,
358            msg_len = payload.len(),
359            "inject: sending payload to PTY"
360        );
361        if let Err(e) = sender(&block_id, payload.as_bytes()) {
362            tracing::error!(
363                target_agent = %req.target_agent,
364                block_id = %block_id,
365                error = %e,
366                "inject: sender failed"
367            );
368            self.log_audit(
369                req.source_agent.as_deref(),
370                &req.target_agent,
371                &block_id,
372                &sanitized,
373                false,
374                Some(&e),
375                &request_id,
376            );
377            return InjectionResponse {
378                success: false,
379                request_id,
380                block_id: Some(block_id),
381                error: Some(e),
382                timestamp: now,
383            };
384        }
385
386        // Spawn 3 delayed \r sends as separate PTY events to ensure submission.
387        let sender_enter = sender.clone();
388        let block_id_enter = block_id.clone();
389        tokio::spawn(async move {
390            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
391            let _ = sender_enter(&block_id_enter, b"\r");
392            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
393            let _ = sender_enter(&block_id_enter, b"\r");
394            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
395            let _ = sender_enter(&block_id_enter, b"\r");
396        });
397
398        // Success
399        self.log_audit(
400            req.source_agent.as_deref(),
401            &req.target_agent,
402            &block_id,
403            &sanitized,
404            true,
405            None,
406            &request_id,
407        );
408
409        InjectionResponse {
410            success: true,
411            request_id,
412            block_id: Some(block_id),
413            error: None,
414            timestamp: now,
415        }
416    }
417
418    /// Get audit log entries, most recent first.
419    pub fn get_audit_log(&self, limit: usize) -> Vec<AuditLogEntry> {
420        let start = if self.audit_log.len() > limit {
421            self.audit_log.len() - limit
422        } else {
423            0
424        };
425        let mut entries: Vec<_> = self.audit_log[start..].to_vec();
426        entries.reverse();
427        entries
428    }
429
430    /// Add an entry to the audit ring buffer.
431    #[allow(clippy::too_many_arguments)]
432    pub(super) fn log_audit(
433        &mut self,
434        source_agent: Option<&str>,
435        target_agent: &str,
436        block_id: &str,
437        message: &str,
438        success: bool,
439        error_message: Option<&str>,
440        request_id: &str,
441    ) {
442        let entry = AuditLogEntry {
443            timestamp: now_unix_millis(),
444            source_agent: source_agent.map(|s| s.to_string()),
445            target_agent: target_agent.to_string(),
446            block_id: block_id.to_string(),
447            message_hash: sha256_hex(message),
448            message_length: message.len(),
449            success,
450            error_message: error_message.map(|s| s.to_string()),
451            request_id: request_id.to_string(),
452        };
453
454        if self.audit_log.len() >= AUDIT_LOG_MAX {
455            self.audit_log.remove(0);
456        }
457        self.audit_log.push(entry);
458    }
459}
460
461impl Default for Handler {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467// ---- Thread-safe wrapper ----
468
469/// Thread-safe wrapper around Handler.
470pub struct ReactiveHandler {
471    inner: Mutex<Handler>,
472}
473
474impl ReactiveHandler {
475    pub fn new() -> Self {
476        Self {
477            inner: Mutex::new(Handler::new()),
478        }
479    }
480
481    pub fn set_input_sender(&self, sender: InputSender) {
482        self.inner.lock().unwrap().set_input_sender(sender);
483    }
484
485    pub fn set_message_sender(&self, sender: MessageSender) {
486        self.inner.lock().unwrap().set_message_sender(sender);
487    }
488
489    #[allow(dead_code)]
490    pub fn set_include_source(&self, include: bool) {
491        self.inner.lock().unwrap().set_include_source(include);
492    }
493
494    pub fn register_agent(
495        &self,
496        agent_id: &str,
497        block_id: &str,
498        tab_id: Option<&str>,
499    ) -> Result<(), String> {
500        self.inner
501            .lock()
502            .unwrap()
503            .register_agent(agent_id, block_id, tab_id)
504    }
505
506    pub fn unregister_agent(&self, agent_id: &str) {
507        self.inner.lock().unwrap().unregister_agent(agent_id);
508    }
509
510    pub fn unregister_block(&self, block_id: &str) {
511        self.inner.lock().unwrap().unregister_block(block_id);
512    }
513
514    /// Return the logical agent_id currently mapped to this block, if any.
515    pub fn agent_id_for_block(&self, block_id: &str) -> Option<String> {
516        self.inner
517            .lock()
518            .unwrap()
519            .block_to_agent
520            .get(block_id)
521            .cloned()
522    }
523
524    #[allow(dead_code)]
525    pub fn update_last_seen(&self, agent_id: &str) {
526        self.inner.lock().unwrap().update_last_seen(agent_id);
527    }
528
529    pub fn get_agent(&self, agent_id: &str) -> Option<AgentRegistration> {
530        self.inner.lock().unwrap().get_agent(agent_id).cloned()
531    }
532
533    #[allow(dead_code)]
534    pub fn get_agent_by_block(&self, block_id: &str) -> Option<AgentRegistration> {
535        self.inner
536            .lock()
537            .unwrap()
538            .get_agent_by_block(block_id)
539            .cloned()
540    }
541
542    pub fn list_agents(&self) -> Vec<AgentRegistration> {
543        self.inner.lock().unwrap().list_agents()
544    }
545
546    pub fn list_active_blocks(&self) -> Vec<String> {
547        self.inner.lock().unwrap().list_active_blocks()
548    }
549
550    pub fn inject_message(&self, req: InjectionRequest) -> InjectionResponse {
551        self.inner.lock().unwrap().inject_message(req)
552    }
553
554    pub fn get_audit_log(&self, limit: usize) -> Vec<AuditLogEntry> {
555        self.inner.lock().unwrap().get_audit_log(limit)
556    }
557}
558
559impl Default for ReactiveHandler {
560    fn default() -> Self {
561        Self::new()
562    }
563}
564
565/// Global reactive handler singleton.
566static GLOBAL_HANDLER: OnceLock<ReactiveHandler> = OnceLock::new();
567
568/// Get or initialize the global reactive handler.
569pub fn get_global_handler() -> &'static ReactiveHandler {
570    GLOBAL_HANDLER.get_or_init(ReactiveHandler::new)
571}