Skip to main content

zentinel_proxy/agents/
manager.rs

1//! Agent manager for coordinating external processing agents.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use base64::{engine::general_purpose::STANDARD, Engine as _};
8use futures::future::join_all;
9use pingora_timeout::timeout;
10use tokio::sync::{RwLock, Semaphore};
11use tracing::{debug, error, info, trace, warn};
12use zentinel_agent_protocol::{
13    v2::MetricsCollector, AgentResponse, EventType, GuardrailInspectEvent, RequestBodyChunkEvent,
14    RequestHeadersEvent, ResponseBodyChunkEvent, ResponseHeadersEvent, WebSocketFrameEvent,
15};
16use zentinel_common::{
17    errors::{ZentinelError, ZentinelResult},
18    types::CircuitBreakerConfig,
19    CircuitBreaker,
20};
21use zentinel_config::{AgentConfig, FailureMode};
22
23use super::agent_v2::AgentV2;
24use super::context::AgentCallContext;
25use super::decision::AgentDecision;
26use super::metrics::AgentMetrics;
27
28/// Agent manager handling all external agents.
29///
30/// All agents use the v2 protocol with bidirectional streaming, capabilities,
31/// health reporting, metrics export, and flow control.
32pub struct AgentManager {
33    /// Configured agents
34    agents: Arc<RwLock<HashMap<String, Arc<AgentV2>>>>,
35    /// Circuit breakers per agent
36    circuit_breakers: Arc<RwLock<HashMap<String, Arc<CircuitBreaker>>>>,
37    /// Global agent metrics
38    metrics: Arc<AgentMetrics>,
39    /// Per-agent semaphores for queue isolation (prevents noisy neighbor problem)
40    agent_semaphores: Arc<RwLock<HashMap<String, Arc<Semaphore>>>>,
41}
42
43impl AgentManager {
44    /// Create new agent manager.
45    ///
46    /// Each agent gets its own semaphore for queue isolation, preventing a slow
47    /// agent from affecting other agents (noisy neighbor problem). The concurrency
48    /// limit is configured per-agent via `max_concurrent_calls` in the agent config.
49    pub async fn new(agents: Vec<AgentConfig>) -> ZentinelResult<Self> {
50        info!(agent_count = agents.len(), "Creating agent manager");
51
52        let mut agent_map = HashMap::new();
53        let breakers = HashMap::new();
54        let mut semaphores = HashMap::new();
55
56        for config in agents {
57            debug!(
58                agent_id = %config.id,
59                transport = ?config.transport,
60                timeout_ms = config.timeout_ms,
61                failure_mode = ?config.failure_mode,
62                max_concurrent_calls = config.max_concurrent_calls,
63                "Configuring agent"
64            );
65
66            // Create per-agent semaphore for queue isolation
67            let semaphore = Arc::new(Semaphore::new(config.max_concurrent_calls));
68
69            let circuit_breaker = Arc::new(CircuitBreaker::new(
70                config.circuit_breaker.unwrap_or_default(),
71            ));
72
73            trace!(
74                agent_id = %config.id,
75                max_concurrent_calls = config.max_concurrent_calls,
76                pool_config = ?config.pool,
77                "Creating agent instance with internal pool"
78            );
79
80            let agent = Arc::new(AgentV2::new(config.clone(), circuit_breaker));
81
82            agent_map.insert(config.id.clone(), agent);
83            semaphores.insert(config.id.clone(), semaphore);
84
85            debug!(
86                agent_id = %config.id,
87                "Agent configured successfully"
88            );
89        }
90
91        info!(
92            configured_agents = agent_map.len(),
93            "Agent manager created successfully with per-agent queue isolation"
94        );
95
96        Ok(Self {
97            agents: Arc::new(RwLock::new(agent_map)),
98            circuit_breakers: Arc::new(RwLock::new(breakers)),
99            metrics: Arc::new(AgentMetrics::default()),
100            agent_semaphores: Arc::new(RwLock::new(semaphores)),
101        })
102    }
103
104    /// Check if any of the given route agents handle a specific event type.
105    pub async fn any_agent_handles_event(
106        &self,
107        route_agents: &[String],
108        event_type: EventType,
109    ) -> bool {
110        let agents = self.agents.read().await;
111        route_agents
112            .iter()
113            .filter_map(|id| agents.get(id))
114            .any(|agent| agent.handles_event(event_type))
115    }
116
117    /// Process request headers through agents.
118    ///
119    /// # Arguments
120    /// * `ctx` - Agent call context with correlation ID and metadata
121    /// * `headers` - Request headers to send to agents
122    /// * `route_agents` - List of (agent_id, failure_mode) tuples from filter chain
123    pub async fn process_request_headers(
124        &self,
125        ctx: &AgentCallContext,
126        mut headers: HashMap<String, Vec<String>>,
127        route_agents: &[(String, FailureMode)],
128    ) -> ZentinelResult<AgentDecision> {
129        let method = headers
130            .remove(":method")
131            .and_then(|mut v| {
132                if v.is_empty() {
133                    None
134                } else {
135                    Some(v.swap_remove(0))
136                }
137            })
138            .unwrap_or_else(|| "GET".to_string());
139        let uri = headers
140            .remove(":path")
141            .and_then(|mut v| {
142                if v.is_empty() {
143                    None
144                } else {
145                    Some(v.swap_remove(0))
146                }
147            })
148            .unwrap_or_else(|| "/".to_string());
149        let event = RequestHeadersEvent {
150            metadata: ctx.metadata.clone(),
151            method,
152            uri,
153            headers,
154        };
155
156        // Use parallel processing for better latency with multiple agents
157        self.process_event_parallel(EventType::RequestHeaders, &event, route_agents, ctx)
158            .await
159    }
160
161    /// Process request body chunk through agents.
162    pub async fn process_request_body(
163        &self,
164        ctx: &AgentCallContext,
165        data: &[u8],
166        is_last: bool,
167        route_agents: &[String],
168    ) -> ZentinelResult<AgentDecision> {
169        // Enforce per-agent body inspection limits before dispatch
170        let inspecting_agents = match self
171            .apply_body_limits(ctx, route_agents, data.len(), EventType::RequestBodyChunk)
172            .await
173        {
174            BodyLimitsResult::Block(decision) => return Ok(*decision),
175            BodyLimitsResult::Proceed(agents) => agents,
176        };
177
178        let event = RequestBodyChunkEvent {
179            correlation_id: ctx.correlation_id.to_string(),
180            data: STANDARD.encode(data),
181            is_last,
182            total_size: ctx.request_body.as_ref().map(|b| b.len()),
183            chunk_index: 0, // Buffer mode sends entire body as single chunk
184            bytes_received: data.len(),
185        };
186
187        self.process_event(EventType::RequestBodyChunk, &event, &inspecting_agents, ctx)
188            .await
189    }
190
191    /// Process a single request body chunk through agents (streaming mode).
192    ///
193    /// Unlike `process_request_body` which is used for buffered mode, this method
194    /// is designed for streaming where chunks are sent individually as they arrive.
195    pub async fn process_request_body_streaming(
196        &self,
197        ctx: &AgentCallContext,
198        data: &[u8],
199        is_last: bool,
200        chunk_index: u32,
201        bytes_received: usize,
202        total_size: Option<usize>,
203        route_agents: &[String],
204    ) -> ZentinelResult<AgentDecision> {
205        trace!(
206            correlation_id = %ctx.correlation_id,
207            chunk_index = chunk_index,
208            chunk_size = data.len(),
209            bytes_received = bytes_received,
210            is_last = is_last,
211            "Processing streaming request body chunk"
212        );
213
214        // Enforce per-agent body inspection limits on the cumulative size
215        let inspecting_agents = match self
216            .apply_body_limits(
217                ctx,
218                route_agents,
219                bytes_received,
220                EventType::RequestBodyChunk,
221            )
222            .await
223        {
224            BodyLimitsResult::Block(decision) => return Ok(*decision),
225            BodyLimitsResult::Proceed(agents) => agents,
226        };
227
228        let event = RequestBodyChunkEvent {
229            correlation_id: ctx.correlation_id.to_string(),
230            data: STANDARD.encode(data),
231            is_last,
232            total_size,
233            chunk_index,
234            bytes_received,
235        };
236
237        self.process_event(EventType::RequestBodyChunk, &event, &inspecting_agents, ctx)
238            .await
239    }
240
241    /// Process a single response body chunk through agents (streaming mode).
242    pub async fn process_response_body_streaming(
243        &self,
244        ctx: &AgentCallContext,
245        data: &[u8],
246        is_last: bool,
247        chunk_index: u32,
248        bytes_sent: usize,
249        total_size: Option<usize>,
250        route_agents: &[String],
251    ) -> ZentinelResult<AgentDecision> {
252        trace!(
253            correlation_id = %ctx.correlation_id,
254            chunk_index = chunk_index,
255            chunk_size = data.len(),
256            bytes_sent = bytes_sent,
257            is_last = is_last,
258            "Processing streaming response body chunk"
259        );
260
261        // Enforce per-agent body inspection limits on the cumulative size
262        let inspecting_agents = match self
263            .apply_body_limits(ctx, route_agents, bytes_sent, EventType::ResponseBodyChunk)
264            .await
265        {
266            BodyLimitsResult::Block(decision) => return Ok(*decision),
267            BodyLimitsResult::Proceed(agents) => agents,
268        };
269
270        let event = ResponseBodyChunkEvent {
271            correlation_id: ctx.correlation_id.to_string(),
272            data: STANDARD.encode(data),
273            is_last,
274            total_size,
275            chunk_index,
276            bytes_sent,
277        };
278
279        self.process_event(
280            EventType::ResponseBodyChunk,
281            &event,
282            &inspecting_agents,
283            ctx,
284        )
285        .await
286    }
287
288    /// Process response headers through agents.
289    pub async fn process_response_headers(
290        &self,
291        ctx: &AgentCallContext,
292        status: u16,
293        headers: &HashMap<String, Vec<String>>,
294        route_agents: &[String],
295    ) -> ZentinelResult<AgentDecision> {
296        let event = ResponseHeadersEvent {
297            correlation_id: ctx.correlation_id.to_string(),
298            status,
299            headers: headers.clone(),
300        };
301
302        self.process_event(EventType::ResponseHeaders, &event, route_agents, ctx)
303            .await
304    }
305
306    /// Process a WebSocket frame through agents.
307    ///
308    /// This is used for WebSocket frame inspection after an upgrade.
309    /// Returns the agent response directly to allow the caller to access
310    /// the websocket_decision field.
311    pub async fn process_websocket_frame(
312        &self,
313        route_id: &str,
314        event: WebSocketFrameEvent,
315    ) -> ZentinelResult<AgentResponse> {
316        trace!(
317            correlation_id = %event.correlation_id,
318            route_id = %route_id,
319            frame_index = event.frame_index,
320            opcode = %event.opcode,
321            "Processing WebSocket frame through agents"
322        );
323
324        // Get relevant agents for this route that handle WebSocket frames
325        let agents = self.agents.read().await;
326        let relevant_agents: Vec<_> = agents
327            .values()
328            .filter(|agent| agent.handles_event(EventType::WebSocketFrame))
329            .collect();
330
331        if relevant_agents.is_empty() {
332            trace!(
333                correlation_id = %event.correlation_id,
334                "No agents handle WebSocket frames, allowing"
335            );
336            return Ok(AgentResponse::websocket_allow());
337        }
338
339        debug!(
340            correlation_id = %event.correlation_id,
341            route_id = %route_id,
342            agent_count = relevant_agents.len(),
343            "Processing WebSocket frame through agents"
344        );
345
346        // Process through each agent sequentially
347        for agent in relevant_agents {
348            // Check circuit breaker
349            if !agent.circuit_breaker().is_closed() {
350                warn!(
351                    agent_id = %agent.id(),
352                    correlation_id = %event.correlation_id,
353                    failure_mode = ?agent.failure_mode(),
354                    "Circuit breaker open, skipping agent for WebSocket frame"
355                );
356
357                if agent.failure_mode() == FailureMode::Closed {
358                    debug!(
359                        correlation_id = %event.correlation_id,
360                        agent_id = %agent.id(),
361                        "Closing WebSocket due to circuit breaker (fail-closed mode)"
362                    );
363                    return Ok(AgentResponse::websocket_close(
364                        1011,
365                        "Service unavailable".to_string(),
366                    ));
367                }
368                continue;
369            }
370
371            // Call agent with timeout
372            let start = Instant::now();
373            let timeout_duration = Duration::from_millis(agent.timeout_ms());
374
375            match timeout(
376                timeout_duration,
377                agent.call_event(EventType::WebSocketFrame, &event),
378            )
379            .await
380            {
381                Ok(Ok(response)) => {
382                    let duration = start.elapsed();
383                    agent.record_success(duration);
384
385                    trace!(
386                        correlation_id = %event.correlation_id,
387                        agent_id = %agent.id(),
388                        duration_ms = duration.as_millis(),
389                        "WebSocket frame agent call succeeded"
390                    );
391
392                    // If agent returned a WebSocket decision that's not Allow, return immediately
393                    if let Some(ref ws_decision) = response.websocket_decision {
394                        if !matches!(
395                            ws_decision,
396                            zentinel_agent_protocol::WebSocketDecision::Allow
397                        ) {
398                            debug!(
399                                correlation_id = %event.correlation_id,
400                                agent_id = %agent.id(),
401                                decision = ?ws_decision,
402                                "Agent returned non-allow WebSocket decision"
403                            );
404                            return Ok(response);
405                        }
406                    }
407                }
408                Ok(Err(e)) => {
409                    agent.record_failure();
410                    error!(
411                        agent_id = %agent.id(),
412                        correlation_id = %event.correlation_id,
413                        error = %e,
414                        duration_ms = start.elapsed().as_millis(),
415                        failure_mode = ?agent.failure_mode(),
416                        "WebSocket frame agent call failed"
417                    );
418
419                    if agent.failure_mode() == FailureMode::Closed {
420                        return Ok(AgentResponse::websocket_close(
421                            1011,
422                            "Agent error".to_string(),
423                        ));
424                    }
425                }
426                Err(_) => {
427                    agent.record_timeout();
428                    warn!(
429                        agent_id = %agent.id(),
430                        correlation_id = %event.correlation_id,
431                        timeout_ms = agent.timeout_ms(),
432                        failure_mode = ?agent.failure_mode(),
433                        "WebSocket frame agent call timed out"
434                    );
435
436                    if agent.failure_mode() == FailureMode::Closed {
437                        return Ok(AgentResponse::websocket_close(
438                            1011,
439                            "Gateway timeout".to_string(),
440                        ));
441                    }
442                }
443            }
444        }
445
446        // All agents allowed the frame
447        Ok(AgentResponse::websocket_allow())
448    }
449
450    /// Process an event through relevant agents.
451    async fn process_event<T: serde::Serialize>(
452        &self,
453        event_type: EventType,
454        event: &T,
455        route_agents: &[String],
456        ctx: &AgentCallContext,
457    ) -> ZentinelResult<AgentDecision> {
458        trace!(
459            correlation_id = %ctx.correlation_id,
460            event_type = ?event_type,
461            route_agents = ?route_agents,
462            "Starting agent event processing"
463        );
464
465        // Get relevant agents for this route and event type
466        let agents = self.agents.read().await;
467        let relevant_agents: Vec<_> = route_agents
468            .iter()
469            .filter_map(|id| agents.get(id))
470            .filter(|agent| agent.handles_event(event_type))
471            .collect();
472
473        if relevant_agents.is_empty() {
474            trace!(
475                correlation_id = %ctx.correlation_id,
476                event_type = ?event_type,
477                "No relevant agents for event, allowing request"
478            );
479            return Ok(AgentDecision::default_allow());
480        }
481
482        debug!(
483            correlation_id = %ctx.correlation_id,
484            event_type = ?event_type,
485            agent_count = relevant_agents.len(),
486            agent_ids = ?relevant_agents.iter().map(|a| a.id()).collect::<Vec<_>>(),
487            "Processing event through agents"
488        );
489
490        // Process through each agent sequentially
491        let mut combined_decision = AgentDecision::default_allow();
492
493        for (agent_index, agent) in relevant_agents.iter().enumerate() {
494            trace!(
495                correlation_id = %ctx.correlation_id,
496                agent_id = %agent.id(),
497                agent_index = agent_index,
498                event_type = ?event_type,
499                "Processing event through agent"
500            );
501
502            // Acquire per-agent semaphore permit (queue isolation)
503            let semaphores = self.agent_semaphores.read().await;
504            let agent_semaphore = semaphores.get(agent.id()).cloned();
505            drop(semaphores); // Release lock before awaiting
506
507            let _permit = match agent_semaphore {
508                Some(semaphore) => {
509                    trace!(
510                        correlation_id = %ctx.correlation_id,
511                        agent_id = %agent.id(),
512                        "Acquiring per-agent semaphore permit"
513                    );
514                    Some(semaphore.acquire_owned().await.map_err(|_| {
515                        error!(
516                            correlation_id = %ctx.correlation_id,
517                            agent_id = %agent.id(),
518                            "Failed to acquire agent call semaphore permit"
519                        );
520                        ZentinelError::Internal {
521                            message: "Failed to acquire agent call permit".to_string(),
522                            correlation_id: Some(ctx.correlation_id.to_string()),
523                            source: None,
524                        }
525                    })?)
526                }
527                None => {
528                    // No semaphore found (shouldn't happen, but fail gracefully)
529                    warn!(
530                        correlation_id = %ctx.correlation_id,
531                        agent_id = %agent.id(),
532                        "No semaphore found for agent, proceeding without queue isolation"
533                    );
534                    None
535                }
536            };
537
538            // Check circuit breaker
539            if !agent.circuit_breaker().is_closed() {
540                warn!(
541                    agent_id = %agent.id(),
542                    correlation_id = %ctx.correlation_id,
543                    failure_mode = ?agent.failure_mode(),
544                    "Circuit breaker open, skipping agent"
545                );
546
547                // Handle based on failure mode
548                if agent.failure_mode() == FailureMode::Closed {
549                    debug!(
550                        correlation_id = %ctx.correlation_id,
551                        agent_id = %agent.id(),
552                        "Blocking request due to circuit breaker (fail-closed mode)"
553                    );
554                    return Ok(AgentDecision::block(503, "Service unavailable")
555                        .with_decided_by(agent.id()));
556                }
557                continue;
558            }
559
560            // Call agent with timeout (using pingora-timeout for efficiency)
561            let start = Instant::now();
562            let timeout_duration = Duration::from_millis(agent.timeout_ms());
563
564            trace!(
565                correlation_id = %ctx.correlation_id,
566                agent_id = %agent.id(),
567                timeout_ms = agent.timeout_ms(),
568                "Calling agent"
569            );
570
571            match timeout(timeout_duration, agent.call_event(event_type, event)).await {
572                Ok(Ok(response)) => {
573                    let duration = start.elapsed();
574                    agent.record_success(duration);
575
576                    trace!(
577                        correlation_id = %ctx.correlation_id,
578                        agent_id = %agent.id(),
579                        duration_ms = duration.as_millis(),
580                        decision = ?response,
581                        "Agent call succeeded"
582                    );
583
584                    // Merge response into combined decision (attributed to this agent)
585                    combined_decision.merge(AgentDecision::from_response(response, agent.id()));
586
587                    // If decision is to block/redirect/challenge, stop processing
588                    if !combined_decision.is_allow() {
589                        debug!(
590                            correlation_id = %ctx.correlation_id,
591                            agent_id = %agent.id(),
592                            decision = ?combined_decision,
593                            "Agent returned blocking decision, stopping agent chain"
594                        );
595                        break;
596                    }
597                }
598                Ok(Err(e)) => {
599                    agent.record_failure();
600                    error!(
601                        agent_id = %agent.id(),
602                        correlation_id = %ctx.correlation_id,
603                        error = %e,
604                        duration_ms = start.elapsed().as_millis(),
605                        failure_mode = ?agent.failure_mode(),
606                        "Agent call failed"
607                    );
608
609                    if agent.failure_mode() == FailureMode::Closed {
610                        return Err(e);
611                    }
612                }
613                Err(_) => {
614                    agent.record_timeout();
615                    warn!(
616                        agent_id = %agent.id(),
617                        correlation_id = %ctx.correlation_id,
618                        timeout_ms = agent.timeout_ms(),
619                        failure_mode = ?agent.failure_mode(),
620                        "Agent call timed out"
621                    );
622
623                    if agent.failure_mode() == FailureMode::Closed {
624                        debug!(
625                            correlation_id = %ctx.correlation_id,
626                            agent_id = %agent.id(),
627                            "Blocking request due to timeout (fail-closed mode)"
628                        );
629                        return Ok(AgentDecision::block(504, "Gateway timeout")
630                            .with_decided_by(agent.id()));
631                    }
632                }
633            }
634        }
635
636        trace!(
637            correlation_id = %ctx.correlation_id,
638            decision = ?combined_decision,
639            agents_processed = relevant_agents.len(),
640            "Agent event processing completed"
641        );
642
643        Ok(combined_decision)
644    }
645
646    /// Process an event through relevant agents with per-filter failure modes.
647    ///
648    /// This is the preferred method for processing events as it respects the
649    /// failure mode configured on each filter, not just the agent's default.
650    async fn process_event_with_failure_modes<T: serde::Serialize>(
651        &self,
652        event_type: EventType,
653        event: &T,
654        route_agents: &[(String, FailureMode)],
655        ctx: &AgentCallContext,
656    ) -> ZentinelResult<AgentDecision> {
657        trace!(
658            correlation_id = %ctx.correlation_id,
659            event_type = ?event_type,
660            route_agents = ?route_agents.iter().map(|(id, _)| id).collect::<Vec<_>>(),
661            "Starting agent event processing with failure modes"
662        );
663
664        // Get relevant agents for this route and event type, preserving failure modes
665        let agents = self.agents.read().await;
666        let relevant_agents: Vec<_> = route_agents
667            .iter()
668            .filter_map(|(id, failure_mode)| agents.get(id).map(|agent| (agent, *failure_mode)))
669            .filter(|(agent, _)| agent.handles_event(event_type))
670            .collect();
671
672        if relevant_agents.is_empty() {
673            trace!(
674                correlation_id = %ctx.correlation_id,
675                event_type = ?event_type,
676                "No relevant agents for event, allowing request"
677            );
678            return Ok(AgentDecision::default_allow());
679        }
680
681        debug!(
682            correlation_id = %ctx.correlation_id,
683            event_type = ?event_type,
684            agent_count = relevant_agents.len(),
685            agent_ids = ?relevant_agents.iter().map(|(a, _)| a.id()).collect::<Vec<_>>(),
686            "Processing event through agents"
687        );
688
689        // Process through each agent sequentially
690        let mut combined_decision = AgentDecision::default_allow();
691
692        for (agent_index, (agent, filter_failure_mode)) in relevant_agents.iter().enumerate() {
693            trace!(
694                correlation_id = %ctx.correlation_id,
695                agent_id = %agent.id(),
696                agent_index = agent_index,
697                event_type = ?event_type,
698                filter_failure_mode = ?filter_failure_mode,
699                "Processing event through agent with filter failure mode"
700            );
701
702            // Acquire per-agent semaphore permit (queue isolation)
703            let semaphores = self.agent_semaphores.read().await;
704            let agent_semaphore = semaphores.get(agent.id()).cloned();
705            drop(semaphores); // Release lock before awaiting
706
707            let _permit = if let Some(semaphore) = agent_semaphore {
708                trace!(
709                    correlation_id = %ctx.correlation_id,
710                    agent_id = %agent.id(),
711                    "Acquiring per-agent semaphore permit"
712                );
713                Some(semaphore.acquire_owned().await.map_err(|_| {
714                    error!(
715                        correlation_id = %ctx.correlation_id,
716                        agent_id = %agent.id(),
717                        "Failed to acquire agent call semaphore permit"
718                    );
719                    ZentinelError::Internal {
720                        message: "Failed to acquire agent call permit".to_string(),
721                        correlation_id: Some(ctx.correlation_id.to_string()),
722                        source: None,
723                    }
724                })?)
725            } else {
726                // No semaphore found (shouldn't happen, but fail gracefully)
727                warn!(
728                    correlation_id = %ctx.correlation_id,
729                    agent_id = %agent.id(),
730                    "No semaphore found for agent, proceeding without queue isolation"
731                );
732                None
733            };
734
735            // Check circuit breaker
736            if !agent.circuit_breaker().is_closed() {
737                warn!(
738                    agent_id = %agent.id(),
739                    correlation_id = %ctx.correlation_id,
740                    filter_failure_mode = ?filter_failure_mode,
741                    "Circuit breaker open, skipping agent"
742                );
743
744                // Handle based on filter's failure mode (not agent's default)
745                if *filter_failure_mode == FailureMode::Closed {
746                    debug!(
747                        correlation_id = %ctx.correlation_id,
748                        agent_id = %agent.id(),
749                        "Blocking request due to circuit breaker (filter fail-closed mode)"
750                    );
751                    return Ok(AgentDecision::block(503, "Service unavailable")
752                        .with_decided_by(agent.id()));
753                }
754                // Fail-open: continue to next agent
755                continue;
756            }
757
758            // Call agent with timeout
759            let start = Instant::now();
760            let timeout_duration = Duration::from_millis(agent.timeout_ms());
761
762            trace!(
763                correlation_id = %ctx.correlation_id,
764                agent_id = %agent.id(),
765                timeout_ms = agent.timeout_ms(),
766                "Calling agent"
767            );
768
769            match timeout(timeout_duration, agent.call_event(event_type, event)).await {
770                Ok(Ok(response)) => {
771                    let duration = start.elapsed();
772                    agent.record_success(duration);
773
774                    trace!(
775                        correlation_id = %ctx.correlation_id,
776                        agent_id = %agent.id(),
777                        duration_ms = duration.as_millis(),
778                        decision = ?response,
779                        "Agent call succeeded"
780                    );
781
782                    // Merge response into combined decision (attributed to this agent)
783                    combined_decision.merge(AgentDecision::from_response(response, agent.id()));
784
785                    // If decision is to block/redirect/challenge, stop processing
786                    if !combined_decision.is_allow() {
787                        debug!(
788                            correlation_id = %ctx.correlation_id,
789                            agent_id = %agent.id(),
790                            decision = ?combined_decision,
791                            "Agent returned blocking decision, stopping agent chain"
792                        );
793                        break;
794                    }
795                }
796                Ok(Err(e)) => {
797                    agent.record_failure();
798                    error!(
799                        agent_id = %agent.id(),
800                        correlation_id = %ctx.correlation_id,
801                        error = %e,
802                        duration_ms = start.elapsed().as_millis(),
803                        filter_failure_mode = ?filter_failure_mode,
804                        "Agent call failed"
805                    );
806
807                    // Use filter's failure mode, not agent's default
808                    if *filter_failure_mode == FailureMode::Closed {
809                        debug!(
810                            correlation_id = %ctx.correlation_id,
811                            agent_id = %agent.id(),
812                            "Blocking request due to agent failure (filter fail-closed mode)"
813                        );
814                        return Ok(AgentDecision::block(503, "Agent unavailable")
815                            .with_decided_by(agent.id()));
816                    }
817                    // Fail-open: continue to next agent (or proceed without this agent)
818                    debug!(
819                        correlation_id = %ctx.correlation_id,
820                        agent_id = %agent.id(),
821                        "Continuing despite agent failure (filter fail-open mode)"
822                    );
823                }
824                Err(_) => {
825                    agent.record_timeout();
826                    warn!(
827                        agent_id = %agent.id(),
828                        correlation_id = %ctx.correlation_id,
829                        timeout_ms = agent.timeout_ms(),
830                        filter_failure_mode = ?filter_failure_mode,
831                        "Agent call timed out"
832                    );
833
834                    // Use filter's failure mode, not agent's default
835                    if *filter_failure_mode == FailureMode::Closed {
836                        debug!(
837                            correlation_id = %ctx.correlation_id,
838                            agent_id = %agent.id(),
839                            "Blocking request due to timeout (filter fail-closed mode)"
840                        );
841                        return Ok(AgentDecision::block(504, "Gateway timeout")
842                            .with_decided_by(agent.id()));
843                    }
844                    // Fail-open: continue to next agent
845                    debug!(
846                        correlation_id = %ctx.correlation_id,
847                        agent_id = %agent.id(),
848                        "Continuing despite timeout (filter fail-open mode)"
849                    );
850                }
851            }
852        }
853
854        trace!(
855            correlation_id = %ctx.correlation_id,
856            decision = ?combined_decision,
857            agents_processed = relevant_agents.len(),
858            "Agent event processing with failure modes completed"
859        );
860
861        Ok(combined_decision)
862    }
863
864    /// Process an event through relevant agents in parallel.
865    ///
866    /// This method executes all agent calls concurrently using `join_all`, which
867    /// significantly improves latency when multiple agents are configured. The
868    /// tradeoff is that if one agent blocks, other agents may still complete
869    /// their work (slight resource waste in blocking scenarios).
870    ///
871    /// # Performance
872    ///
873    /// For N agents with latency L each:
874    /// - Sequential: O(N * L)
875    /// - Parallel: O(L) (assuming sufficient concurrency)
876    ///
877    /// This is the preferred method for most use cases.
878    async fn process_event_parallel<T: serde::Serialize + Sync>(
879        &self,
880        event_type: EventType,
881        event: &T,
882        route_agents: &[(String, FailureMode)],
883        ctx: &AgentCallContext,
884    ) -> ZentinelResult<AgentDecision> {
885        trace!(
886            correlation_id = %ctx.correlation_id,
887            event_type = ?event_type,
888            route_agents = ?route_agents.iter().map(|(id, _)| id).collect::<Vec<_>>(),
889            "Starting parallel agent event processing"
890        );
891
892        // Get relevant agents for this route and event type
893        let agents = self.agents.read().await;
894        let semaphores = self.agent_semaphores.read().await;
895
896        // Collect agent info upfront to minimize lock duration
897        let agent_info: Vec<_> = route_agents
898            .iter()
899            .filter_map(|(id, failure_mode)| {
900                let agent = agents.get(id)?;
901                if !agent.handles_event(event_type) {
902                    return None;
903                }
904                let semaphore = semaphores.get(id).cloned();
905                Some((Arc::clone(agent), *failure_mode, semaphore))
906            })
907            .collect();
908
909        // Release locks early
910        drop(agents);
911        drop(semaphores);
912
913        if agent_info.is_empty() {
914            trace!(
915                correlation_id = %ctx.correlation_id,
916                event_type = ?event_type,
917                "No relevant agents for event, allowing request"
918            );
919            return Ok(AgentDecision::default_allow());
920        }
921
922        debug!(
923            correlation_id = %ctx.correlation_id,
924            event_type = ?event_type,
925            agent_count = agent_info.len(),
926            agent_ids = ?agent_info.iter().map(|(a, _, _)| a.id()).collect::<Vec<_>>(),
927            "Processing event through agents in parallel"
928        );
929
930        // Spawn all agent calls concurrently
931        let futures: Vec<_> = agent_info
932            .iter()
933            .map(|(agent, filter_failure_mode, semaphore)| {
934                let agent = Arc::clone(agent);
935                let filter_failure_mode = *filter_failure_mode;
936                let semaphore = semaphore.clone();
937                let correlation_id = ctx.correlation_id.clone();
938
939                async move {
940                    // Acquire per-agent semaphore permit (queue isolation)
941                    let _permit = if let Some(sem) = semaphore {
942                        match sem.acquire_owned().await {
943                            Ok(permit) => Some(permit),
944                            Err(_) => {
945                                error!(
946                                    correlation_id = %correlation_id,
947                                    agent_id = %agent.id(),
948                                    "Failed to acquire agent semaphore permit"
949                                );
950                                return Err((
951                                    agent.id().to_string(),
952                                    filter_failure_mode,
953                                    "Failed to acquire permit".to_string(),
954                                ));
955                            }
956                        }
957                    } else {
958                        None
959                    };
960
961                    // Check circuit breaker
962                    if !agent.circuit_breaker().is_closed() {
963                        warn!(
964                            agent_id = %agent.id(),
965                            correlation_id = %correlation_id,
966                            filter_failure_mode = ?filter_failure_mode,
967                            "Circuit breaker open, skipping agent"
968                        );
969                        return Err((
970                            agent.id().to_string(),
971                            filter_failure_mode,
972                            "Circuit breaker open".to_string(),
973                        ));
974                    }
975
976                    // Call agent with timeout
977                    let start = Instant::now();
978                    let timeout_duration = Duration::from_millis(agent.timeout_ms());
979
980                    match timeout(timeout_duration, agent.call_event(event_type, event)).await {
981                        Ok(Ok(response)) => {
982                            let duration = start.elapsed();
983                            agent.record_success(duration);
984                            trace!(
985                                correlation_id = %correlation_id,
986                                agent_id = %agent.id(),
987                                duration_ms = duration.as_millis(),
988                                "Parallel agent call succeeded"
989                            );
990                            Ok((agent.id().to_string(), response))
991                        }
992                        Ok(Err(e)) => {
993                            agent.record_failure();
994                            error!(
995                                agent_id = %agent.id(),
996                                correlation_id = %correlation_id,
997                                error = %e,
998                                duration_ms = start.elapsed().as_millis(),
999                                filter_failure_mode = ?filter_failure_mode,
1000                                "Parallel agent call failed"
1001                            );
1002                            Err((
1003                                agent.id().to_string(),
1004                                filter_failure_mode,
1005                                format!("Agent error: {}", e),
1006                            ))
1007                        }
1008                        Err(_) => {
1009                            agent.record_timeout();
1010                            warn!(
1011                                agent_id = %agent.id(),
1012                                correlation_id = %correlation_id,
1013                                timeout_ms = agent.timeout_ms(),
1014                                filter_failure_mode = ?filter_failure_mode,
1015                                "Parallel agent call timed out"
1016                            );
1017                            Err((
1018                                agent.id().to_string(),
1019                                filter_failure_mode,
1020                                "Timeout".to_string(),
1021                            ))
1022                        }
1023                    }
1024                }
1025            })
1026            .collect();
1027
1028        // Execute all agent calls in parallel
1029        let results = join_all(futures).await;
1030
1031        // Process results and merge decisions
1032        let mut combined_decision = AgentDecision::default_allow();
1033        let mut blocking_error: Option<AgentDecision> = None;
1034
1035        for result in results {
1036            match result {
1037                Ok((agent_id, response)) => {
1038                    let decision = AgentDecision::from_response(response, &agent_id);
1039
1040                    // Check for blocking decision
1041                    if !decision.is_allow() {
1042                        debug!(
1043                            correlation_id = %ctx.correlation_id,
1044                            agent_id = %agent_id,
1045                            decision = ?decision,
1046                            "Agent returned blocking decision"
1047                        );
1048                        // Return first blocking decision immediately
1049                        return Ok(decision);
1050                    }
1051
1052                    combined_decision.merge(decision);
1053                }
1054                Err((agent_id, failure_mode, reason)) => {
1055                    // Handle failure based on filter's failure mode
1056                    if failure_mode == FailureMode::Closed && blocking_error.is_none() {
1057                        debug!(
1058                            correlation_id = %ctx.correlation_id,
1059                            agent_id = %agent_id,
1060                            reason = %reason,
1061                            "Agent failure in fail-closed mode"
1062                        );
1063                        // Store blocking error but continue processing other results
1064                        // in case another agent returned a more specific block
1065                        let status = if reason.contains("Timeout") { 504 } else { 503 };
1066                        let message = if reason.contains("Timeout") {
1067                            "Gateway timeout"
1068                        } else {
1069                            "Service unavailable"
1070                        };
1071                        blocking_error =
1072                            Some(AgentDecision::block(status, message).with_decided_by(&agent_id));
1073                    } else {
1074                        // Fail-open: log and continue
1075                        debug!(
1076                            correlation_id = %ctx.correlation_id,
1077                            agent_id = %agent_id,
1078                            reason = %reason,
1079                            "Agent failure in fail-open mode, continuing"
1080                        );
1081                    }
1082                }
1083            }
1084        }
1085
1086        // If we have a fail-closed error and no explicit block, return the error
1087        if let Some(error_decision) = blocking_error {
1088            return Ok(error_decision);
1089        }
1090
1091        trace!(
1092            correlation_id = %ctx.correlation_id,
1093            decision = ?combined_decision,
1094            agents_processed = agent_info.len(),
1095            "Parallel agent event processing completed"
1096        );
1097
1098        Ok(combined_decision)
1099    }
1100
1101    /// Call a named agent with a guardrail inspect event.
1102    ///
1103    /// Looks up the agent by name, checks circuit breaker and timeout,
1104    /// then sends the event and returns the response.
1105    pub async fn call_guardrail_agent(
1106        &self,
1107        agent_name: &str,
1108        event: GuardrailInspectEvent,
1109    ) -> ZentinelResult<AgentResponse> {
1110        let agents = self.agents.read().await;
1111        let agent = agents.get(agent_name).ok_or_else(|| ZentinelError::Agent {
1112            agent: agent_name.to_string(),
1113            message: format!("Agent '{}' not found", agent_name),
1114            event: "guardrail_inspect".to_string(),
1115            source: None,
1116        })?;
1117
1118        let agent = Arc::clone(agent);
1119        drop(agents); // Release lock before calling
1120
1121        // Acquire per-agent semaphore permit
1122        let semaphores = self.agent_semaphores.read().await;
1123        let semaphore = semaphores.get(agent_name).cloned();
1124        drop(semaphores);
1125
1126        let _permit = if let Some(sem) = semaphore {
1127            Some(
1128                sem.acquire_owned()
1129                    .await
1130                    .map_err(|_| ZentinelError::Agent {
1131                        agent: agent_name.to_string(),
1132                        message: "Failed to acquire agent call permit".to_string(),
1133                        event: "guardrail_inspect".to_string(),
1134                        source: None,
1135                    })?,
1136            )
1137        } else {
1138            None
1139        };
1140
1141        // Check circuit breaker
1142        if !agent.circuit_breaker().is_closed() {
1143            return Err(ZentinelError::Agent {
1144                agent: agent_name.to_string(),
1145                message: "Circuit breaker open".to_string(),
1146                event: "guardrail_inspect".to_string(),
1147                source: None,
1148            });
1149        }
1150
1151        let start = Instant::now();
1152        let timeout_duration = Duration::from_millis(agent.timeout_ms());
1153
1154        match timeout(timeout_duration, agent.call_guardrail_inspect(&event)).await {
1155            Ok(Ok(response)) => {
1156                agent.record_success(start.elapsed());
1157                Ok(response)
1158            }
1159            Ok(Err(e)) => {
1160                agent.record_failure();
1161                Err(e)
1162            }
1163            Err(_) => {
1164                agent.record_timeout();
1165                Err(ZentinelError::Agent {
1166                    agent: agent_name.to_string(),
1167                    message: format!(
1168                        "Guardrail agent call timed out after {}ms",
1169                        timeout_duration.as_millis()
1170                    ),
1171                    event: "guardrail_inspect".to_string(),
1172                    source: None,
1173                })
1174            }
1175        }
1176    }
1177
1178    /// Initialize agent connections.
1179    pub async fn initialize(&self) -> ZentinelResult<()> {
1180        let agents = self.agents.read().await;
1181
1182        info!(agent_count = agents.len(), "Initializing agent connections");
1183
1184        let mut initialized_count = 0;
1185        let mut failed_count = 0;
1186
1187        for (id, agent) in agents.iter() {
1188            debug!(agent_id = %id, "Initializing agent connection");
1189            if let Err(e) = agent.initialize().await {
1190                error!(
1191                    agent_id = %id,
1192                    error = %e,
1193                    "Failed to initialize agent"
1194                );
1195                failed_count += 1;
1196                // Continue with other agents
1197            } else {
1198                trace!(agent_id = %id, "Agent initialized successfully");
1199                initialized_count += 1;
1200            }
1201        }
1202
1203        info!(
1204            initialized = initialized_count,
1205            failed = failed_count,
1206            total = agents.len(),
1207            "Agent initialization complete"
1208        );
1209
1210        Ok(())
1211    }
1212
1213    /// Shutdown all agents.
1214    pub async fn shutdown(&self) {
1215        let agents = self.agents.read().await;
1216
1217        info!(agent_count = agents.len(), "Shutting down agent manager");
1218
1219        for (id, agent) in agents.iter() {
1220            debug!(agent_id = %id, "Shutting down agent");
1221            agent.shutdown().await;
1222            trace!(agent_id = %id, "Agent shutdown complete");
1223        }
1224
1225        info!("Agent manager shutdown complete");
1226    }
1227
1228    /// Release per-request agent state after a request completes.
1229    ///
1230    /// Clears the correlation affinity (headers → body chunk connection
1231    /// pinning) on every agent pool. Affinities that are never released here
1232    /// are reclaimed by the pool maintenance TTL sweep.
1233    pub async fn end_request(&self, correlation_id: &str) {
1234        let agents = self.agents.read().await;
1235        for agent in agents.values() {
1236            agent.clear_correlation_affinity(correlation_id);
1237        }
1238    }
1239
1240    /// Get agent metrics.
1241    pub fn metrics(&self) -> &AgentMetrics {
1242        &self.metrics
1243    }
1244
1245    /// Get agent IDs that handle a specific event type.
1246    ///
1247    /// This is useful for pre-filtering agents before making calls,
1248    /// e.g., to check if any agents handle WebSocket frames.
1249    pub fn get_agents_for_event(&self, event_type: EventType) -> Vec<String> {
1250        // Use try_read to avoid blocking - return empty if lock is held
1251        // This is acceptable since this is only used for informational purposes
1252        if let Ok(agents) = self.agents.try_read() {
1253            agents
1254                .values()
1255                .filter(|agent| agent.handles_event(event_type))
1256                .map(|agent| agent.id().to_string())
1257                .collect()
1258        } else {
1259            Vec::new()
1260        }
1261    }
1262
1263    /// Get pool metrics collectors from all agents.
1264    ///
1265    /// Returns a vector of (agent_id, MetricsCollector) pairs.
1266    /// These can be registered with the MetricsManager to include agent pool
1267    /// metrics in the /metrics endpoint output.
1268    pub async fn get_v2_pool_metrics(&self) -> Vec<(String, Arc<MetricsCollector>)> {
1269        let agents = self.agents.read().await;
1270        agents
1271            .iter()
1272            .map(|(id, agent)| (id.clone(), agent.pool_metrics_collector_arc()))
1273            .collect()
1274    }
1275
1276    /// Export prometheus metrics from all agent pools.
1277    ///
1278    /// Returns the combined prometheus-formatted metrics from all agent pools.
1279    pub async fn export_v2_pool_metrics(&self) -> String {
1280        let agents = self.agents.read().await;
1281        let mut output = String::new();
1282
1283        for (id, agent) in agents.iter() {
1284            let pool_metrics = agent.export_prometheus();
1285            if !pool_metrics.is_empty() {
1286                output.push_str(&format!("\n# Agent pool metrics: {}\n", id));
1287                output.push_str(&pool_metrics);
1288            }
1289        }
1290
1291        output
1292    }
1293
1294    /// Get an agent's metrics collector by ID.
1295    ///
1296    /// Returns None if the agent doesn't exist.
1297    pub async fn get_v2_metrics_collector(&self, agent_id: &str) -> Option<Arc<MetricsCollector>> {
1298        let agents = self.agents.read().await;
1299        agents
1300            .get(agent_id)
1301            .map(|agent| agent.pool_metrics_collector_arc())
1302    }
1303
1304    /// Enforce per-agent body inspection limits for a body of `body_size` bytes.
1305    ///
1306    /// Agents whose limit is exceeded are handled according to their failure
1307    /// mode: fail-closed produces a 413 Block decision, fail-open skips that
1308    /// agent loudly (warn + metric) while agents within their limit still
1309    /// inspect the body.
1310    async fn apply_body_limits(
1311        &self,
1312        ctx: &AgentCallContext,
1313        route_agents: &[String],
1314        body_size: usize,
1315        event_type: EventType,
1316    ) -> BodyLimitsResult {
1317        let agents = self.agents.read().await;
1318        let limits: Vec<(String, FailureMode, usize)> = route_agents
1319            .iter()
1320            .filter_map(|id| agents.get(id))
1321            .filter(|agent| agent.handles_event(event_type))
1322            .map(|agent| {
1323                let limit = if event_type == EventType::ResponseBodyChunk {
1324                    agent.max_response_body_bytes()
1325                } else {
1326                    agent.max_request_body_bytes()
1327                };
1328                (agent.id().to_string(), agent.failure_mode(), limit)
1329            })
1330            .collect();
1331
1332        let outcome = evaluate_body_limits(&limits, body_size);
1333
1334        for (agent_id, limit) in &outcome.skipped {
1335            warn!(
1336                correlation_id = %ctx.correlation_id,
1337                agent_id = %agent_id,
1338                body_size = body_size,
1339                limit = limit,
1340                event_type = ?event_type,
1341                "Body exceeds agent inspection limit, skipping agent (fail-open)"
1342            );
1343            if let Some(agent) = agents.get(agent_id) {
1344                agent.metrics().record_body_size_skip();
1345            }
1346        }
1347
1348        if let Some((agent_id, limit)) = outcome.blocked_by {
1349            warn!(
1350                correlation_id = %ctx.correlation_id,
1351                agent_id = %agent_id,
1352                body_size = body_size,
1353                limit = limit,
1354                event_type = ?event_type,
1355                "Body exceeds agent inspection limit, blocking request (fail-closed)"
1356            );
1357            return BodyLimitsResult::Block(Box::new(
1358                AgentDecision::block(413, "Payload too large for security inspection")
1359                    .with_decided_by(agent_id),
1360            ));
1361        }
1362
1363        BodyLimitsResult::Proceed(outcome.allowed)
1364    }
1365}
1366
1367/// Result of enforcing body inspection limits.
1368enum BodyLimitsResult {
1369    /// Agents (within their limits) that may inspect the body.
1370    Proceed(Vec<String>),
1371    /// A fail-closed agent's limit was exceeded; the request must be blocked.
1372    Block(Box<AgentDecision>),
1373}
1374
1375/// Outcome of evaluating a body size against per-agent inspection limits.
1376#[derive(Debug)]
1377struct BodyLimitOutcome {
1378    /// Agents whose limit accommodates the body
1379    allowed: Vec<String>,
1380    /// Fail-open agents skipped because the body exceeds their limit (id, limit)
1381    skipped: Vec<(String, usize)>,
1382    /// First fail-closed agent whose limit was exceeded (id, limit)
1383    blocked_by: Option<(String, usize)>,
1384}
1385
1386/// Evaluate a body size against per-agent `(id, failure_mode, limit)` entries.
1387fn evaluate_body_limits(
1388    agents: &[(String, FailureMode, usize)],
1389    body_size: usize,
1390) -> BodyLimitOutcome {
1391    let mut outcome = BodyLimitOutcome {
1392        allowed: Vec::new(),
1393        skipped: Vec::new(),
1394        blocked_by: None,
1395    };
1396
1397    for (id, failure_mode, limit) in agents {
1398        if body_size <= *limit {
1399            outcome.allowed.push(id.clone());
1400        } else if *failure_mode == FailureMode::Closed {
1401            outcome.blocked_by = Some((id.clone(), *limit));
1402            break;
1403        } else {
1404            outcome.skipped.push((id.clone(), *limit));
1405        }
1406    }
1407
1408    outcome
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414
1415    fn limits() -> Vec<(String, FailureMode, usize)> {
1416        vec![
1417            ("waf".to_string(), FailureMode::Closed, 1024),
1418            ("audit".to_string(), FailureMode::Open, 512),
1419            ("dlp".to_string(), FailureMode::Open, 4096),
1420        ]
1421    }
1422
1423    #[test]
1424    fn body_within_all_limits_allows_all_agents() {
1425        let outcome = evaluate_body_limits(&limits(), 256);
1426        assert_eq!(outcome.allowed, vec!["waf", "audit", "dlp"]);
1427        assert!(outcome.skipped.is_empty());
1428        assert!(outcome.blocked_by.is_none());
1429    }
1430
1431    #[test]
1432    fn oversized_body_blocks_on_fail_closed_agent() {
1433        let outcome = evaluate_body_limits(&limits(), 2048);
1434        assert_eq!(
1435            outcome.blocked_by,
1436            Some(("waf".to_string(), 1024)),
1437            "fail-closed agent over its limit must block"
1438        );
1439    }
1440
1441    #[test]
1442    fn oversized_body_skips_fail_open_agent_and_keeps_others() {
1443        let outcome = evaluate_body_limits(&limits(), 600);
1444        assert_eq!(outcome.allowed, vec!["waf", "dlp"]);
1445        assert_eq!(outcome.skipped, vec![("audit".to_string(), 512)]);
1446        assert!(outcome.blocked_by.is_none());
1447    }
1448
1449    #[test]
1450    fn no_agents_yields_empty_outcome() {
1451        let outcome = evaluate_body_limits(&[], 1_000_000);
1452        assert!(outcome.allowed.is_empty());
1453        assert!(outcome.skipped.is_empty());
1454        assert!(outcome.blocked_by.is_none());
1455    }
1456}