Skip to main content

ai_agents_process/
processor.rs

1//! Process processor for executing input/output transformations
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::time::Instant;
8
9use ai_agents_core::{AgentError, ChatMessage, LLMProvider, Result};
10use ai_agents_llm::LLMRegistry;
11
12use super::config::*;
13
14#[derive(Debug, Clone, Default)]
15pub struct ProcessData {
16    pub content: String,
17    pub original: String,
18    pub context: HashMap<String, serde_json::Value>,
19    pub metadata: ProcessMetadata,
20}
21
22#[derive(Debug, Clone, Default)]
23pub struct ProcessMetadata {
24    pub stages_executed: Vec<String>,
25    pub timing: HashMap<String, u64>,
26    pub warnings: Vec<String>,
27    pub rejected: bool,
28    pub rejection_reason: Option<String>,
29}
30
31impl ProcessData {
32    pub fn new(content: impl Into<String>) -> Self {
33        let content = content.into();
34        Self {
35            original: content.clone(),
36            content,
37            context: HashMap::new(),
38            metadata: ProcessMetadata::default(),
39        }
40    }
41
42    pub fn with_context(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
43        self.context.insert(key.into(), value);
44        self
45    }
46}
47
48/// Observability hint that describes the kind of process stage being executed.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ProcessPurposeHint {
51    Detect,
52    Extract,
53    Validate,
54    Transform,
55    Other,
56}
57
58/// Boxed future used by process observers to wrap stage execution.
59pub type ProcessStageFuture<'a> = Pin<Box<dyn Future<Output = Result<ProcessData>> + Send + 'a>>;
60
61/// Runtime-provided hook for observing stages without adding an observability dependency.
62pub trait ProcessStageObserver: Send + Sync {
63    /// Wraps one process stage future with external instrumentation.
64    fn observe<'a>(
65        &'a self,
66        hint: ProcessPurposeHint,
67        future: ProcessStageFuture<'a>,
68    ) -> ProcessStageFuture<'a>;
69}
70
71/// Executes configured input and output process stages.
72pub struct ProcessProcessor {
73    config: ProcessConfig,
74    llm_registry: Option<Arc<LLMRegistry>>,
75    stage_observer: Option<Arc<dyn ProcessStageObserver>>,
76}
77
78impl std::fmt::Debug for ProcessProcessor {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("ProcessProcessor")
81            .field("config", &self.config)
82            .field("has_llm_registry", &self.llm_registry.is_some())
83            .field("has_stage_observer", &self.stage_observer.is_some())
84            .finish()
85    }
86}
87
88impl Default for ProcessProcessor {
89    fn default() -> Self {
90        Self::new(ProcessConfig::default())
91    }
92}
93
94impl ProcessProcessor {
95    pub fn new(config: ProcessConfig) -> Self {
96        Self {
97            config,
98            llm_registry: None,
99            stage_observer: None,
100        }
101    }
102
103    /// Exposes the configured stages for provider-free construction preflight.
104    pub fn config(&self) -> &ProcessConfig {
105        &self.config
106    }
107
108    pub fn with_llm_registry(mut self, registry: Arc<LLMRegistry>) -> Self {
109        self.llm_registry = Some(registry);
110        self
111    }
112
113    /// Attaches a runtime observer used to instrument each process stage.
114    pub fn with_stage_observer(mut self, observer: Arc<dyn ProcessStageObserver>) -> Self {
115        self.stage_observer = Some(observer);
116        self
117    }
118
119    /// Returns the first meaningful purpose hint for the input pipeline.
120    pub fn input_purpose_hint(&self) -> ProcessPurposeHint {
121        purpose_hint_for_stages(&self.config.input)
122    }
123
124    /// Returns the first meaningful purpose hint for the output pipeline.
125    pub fn output_purpose_hint(&self) -> ProcessPurposeHint {
126        purpose_hint_for_stages(&self.config.output)
127    }
128
129    pub async fn process_input(&self, input: &str) -> Result<ProcessData> {
130        let mut data = ProcessData::new(input);
131
132        for stage in &self.config.input {
133            data = self.execute_stage(stage, data).await?;
134            if data.metadata.rejected {
135                break;
136            }
137        }
138
139        Ok(data)
140    }
141
142    pub async fn process_output(
143        &self,
144        output: &str,
145        input_context: &HashMap<String, serde_json::Value>,
146    ) -> Result<ProcessData> {
147        let mut data = ProcessData::new(output);
148        data.context = input_context.clone();
149
150        for stage in &self.config.output {
151            data = self.execute_stage(stage, data).await?;
152            if data.metadata.rejected {
153                break;
154            }
155        }
156
157        Ok(data)
158    }
159
160    fn execute_stage<'a>(
161        &'a self,
162        stage: &'a ProcessStage,
163        data: ProcessData,
164    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ProcessData>> + Send + 'a>> {
165        Box::pin(async move {
166            let start = Instant::now();
167            let stage_name = stage
168                .id()
169                .map(String::from)
170                .unwrap_or_else(|| self.get_stage_type_name(stage));
171
172            // Check condition before executing stage
173            if let Some(condition) = stage.condition()
174                && !self.evaluate_condition_expr(condition, &data)
175            {
176                if self.config.settings.debug.log_stages {
177                    tracing::debug!(
178                        "[Process] Stage skipped (condition not met): {}",
179                        stage_name
180                    );
181                }
182                return Ok(data);
183            }
184
185            if self.config.settings.debug.log_stages {
186                tracing::debug!("[Process] Executing stage: {}", stage_name);
187            }
188
189            let data_clone = data.clone();
190            let hint = process_purpose_hint_for_stage(stage);
191            let run_stage: ProcessStageFuture<'a> = Box::pin(async move {
192                match stage {
193                    ProcessStage::Normalize(s) => self.execute_normalize(&s.config, data).await,
194                    ProcessStage::Detect(s) => self.execute_detect(&s.config, data).await,
195                    ProcessStage::Extract(s) => self.execute_extract(&s.config, data).await,
196                    ProcessStage::Sanitize(s) => self.execute_sanitize(&s.config, data).await,
197                    ProcessStage::Transform(s) => self.execute_transform(&s.config, data).await,
198                    ProcessStage::Validate(s) => self.execute_validate(&s.config, data).await,
199                    ProcessStage::Format(s) => self.execute_format(&s.config, data).await,
200                    ProcessStage::Enrich(s) => self.execute_enrich(&s.config, data).await,
201                    ProcessStage::Conditional(s) => self.execute_conditional(&s.config, data).await,
202                }
203            });
204            let result = if let Some(observer) = self.stage_observer.as_ref() {
205                observer.observe(hint, run_stage).await
206            } else {
207                run_stage.await
208            };
209
210            match result {
211                Ok(mut d) => {
212                    d.metadata.stages_executed.push(stage_name.clone());
213                    if self.config.settings.debug.include_timing {
214                        d.metadata
215                            .timing
216                            .insert(stage_name, start.elapsed().as_millis() as u64);
217                    }
218                    Ok(d)
219                }
220                Err(error @ AgentError::Config(_))
221                    if self
222                        .llm_registry
223                        .as_ref()
224                        .is_some_and(|registry| registry.router_roles().is_some()) =>
225                {
226                    Err(error)
227                }
228                Err(e) => {
229                    let mut fallback_data = data_clone;
230                    match self.config.settings.on_stage_error.default {
231                        StageErrorAction::Stop => Err(e),
232                        StageErrorAction::Continue => {
233                            fallback_data
234                                .metadata
235                                .warnings
236                                .push(format!("Stage {} failed: {}", stage_name, e));
237                            Ok(fallback_data)
238                        }
239                        StageErrorAction::Retry => {
240                            if let Some(retry_config) = &self.config.settings.on_stage_error.retry {
241                                for _ in 0..retry_config.max_retries {
242                                    tokio::time::sleep(std::time::Duration::from_millis(
243                                        retry_config.backoff_ms,
244                                    ))
245                                    .await;
246                                }
247                            }
248                            fallback_data
249                                .metadata
250                                .warnings
251                                .push(format!("Stage {} failed after retries: {}", stage_name, e));
252                            Ok(fallback_data)
253                        }
254                    }
255                }
256            }
257        })
258    }
259
260    fn get_stage_type_name(&self, stage: &ProcessStage) -> String {
261        match stage {
262            ProcessStage::Normalize(_) => "normalize".to_string(),
263            ProcessStage::Detect(_) => "detect".to_string(),
264            ProcessStage::Extract(_) => "extract".to_string(),
265            ProcessStage::Sanitize(_) => "sanitize".to_string(),
266            ProcessStage::Transform(_) => "transform".to_string(),
267            ProcessStage::Validate(_) => "validate".to_string(),
268            ProcessStage::Format(_) => "format".to_string(),
269            ProcessStage::Enrich(_) => "enrich".to_string(),
270            ProcessStage::Conditional(_) => "conditional".to_string(),
271        }
272    }
273
274    async fn execute_normalize(
275        &self,
276        config: &NormalizeConfig,
277        mut data: ProcessData,
278    ) -> Result<ProcessData> {
279        let mut content = data.content.clone();
280
281        if config.trim {
282            content = content.trim().to_string();
283        }
284
285        if config.collapse_whitespace {
286            content = content.split_whitespace().collect::<Vec<_>>().join(" ");
287        }
288
289        if config.lowercase {
290            content = content.to_lowercase();
291        }
292
293        // Unicode normalization would require unicode-normalization crate
294        // For now, we skip it as it's optional
295
296        data.content = content;
297        Ok(data)
298    }
299
300    async fn execute_detect(
301        &self,
302        config: &DetectConfig,
303        mut data: ProcessData,
304    ) -> Result<ProcessData> {
305        let llm = self.get_llm(ai_agents_llm::LLMRole::ProcessDetect, config.llm.as_deref())?;
306
307        let detection_types: Vec<&str> = config
308            .detect
309            .iter()
310            .map(|d| match d {
311                DetectionType::Language => "language (ISO 639-1 code)",
312                DetectionType::Sentiment => "sentiment (positive, negative, neutral)",
313                DetectionType::Intent => "intent",
314                DetectionType::Topic => "topic",
315                DetectionType::Formality => "formality (formal, informal)",
316                DetectionType::Urgency => "urgency (low, medium, high, critical)",
317            })
318            .collect();
319
320        let intents_desc = if !config.intents.is_empty() {
321            let intents: Vec<String> = config
322                .intents
323                .iter()
324                .map(|i| format!("- {}: {}", i.id, i.description))
325                .collect();
326            format!("\n\nAvailable intents:\n{}", intents.join("\n"))
327        } else {
328            String::new()
329        };
330
331        let prompt = format!(
332            "Analyze the following text and detect: {}\n{}\n\n\
333             Respond with JSON only: {{\"language\": \"...\", \"sentiment\": \"...\", \"intent\": \"...\", ...}}\n\n\
334             Text: {}",
335            detection_types.join(", "),
336            intents_desc,
337            data.content
338        );
339
340        let messages = vec![ChatMessage::user(&prompt)];
341        let response = llm
342            .complete(&messages, None)
343            .await
344            .map_err(|e| AgentError::LLM(e.to_string()))?;
345
346        if let Ok(result) =
347            serde_json::from_str::<serde_json::Value>(&extract_json(&response.content))
348        {
349            for (key, context_path) in &config.store_in_context {
350                if let Some(value) = result.get(key) {
351                    data.context.insert(context_path.clone(), value.clone());
352                }
353            }
354            data.context.insert("detection".to_string(), result);
355        }
356
357        Ok(data)
358    }
359
360    async fn execute_extract(
361        &self,
362        config: &ExtractConfig,
363        mut data: ProcessData,
364    ) -> Result<ProcessData> {
365        let llm = self.get_llm(
366            ai_agents_llm::LLMRole::ProcessExtract,
367            config.llm.as_deref(),
368        )?;
369
370        let schema_desc: Vec<String> = config
371            .schema
372            .iter()
373            .map(|(name, schema)| {
374                let type_str = format!("{:?}", schema.field_type).to_lowercase();
375                let desc = schema.description.as_deref().unwrap_or("");
376                let values = if !schema.values.is_empty() {
377                    format!(" (values: {})", schema.values.join(", "))
378                } else {
379                    String::new()
380                };
381                let required = if schema.required { " [required]" } else { "" };
382                format!("- {}: {} - {}{}{}", name, type_str, desc, values, required)
383            })
384            .collect();
385
386        let prompt = format!(
387            "Extract the following fields from the text:\n{}\n\n\
388             Respond with JSON only. Use null for fields not found.\n\n\
389             Text: {}",
390            schema_desc.join("\n"),
391            data.content
392        );
393
394        let messages = vec![ChatMessage::user(&prompt)];
395        let response = llm
396            .complete(&messages, None)
397            .await
398            .map_err(|e| AgentError::LLM(e.to_string()))?;
399
400        if let Ok(result) =
401            serde_json::from_str::<serde_json::Value>(&extract_json(&response.content))
402        {
403            if let Some(context_path) = &config.store_in_context {
404                data.context.insert(context_path.clone(), result.clone());
405            }
406            data.context.insert("extracted".to_string(), result);
407        }
408
409        Ok(data)
410    }
411
412    async fn execute_sanitize(
413        &self,
414        config: &SanitizeConfig,
415        mut data: ProcessData,
416    ) -> Result<ProcessData> {
417        let llm = self.get_llm(
418            ai_agents_llm::LLMRole::ProcessSanitize,
419            config.llm.as_deref(),
420        )?;
421
422        let mut instructions = Vec::new();
423
424        if let Some(pii_config) = &config.pii
425            && !pii_config.types.is_empty()
426        {
427            let pii_types: Vec<&str> = pii_config.types.iter().map(pii_type_prompt_label).collect();
428            let action = match pii_config.action {
429                PIIAction::Mask => format!(
430                    "Replace every detected value with '{}' and retain none of its original characters",
431                    pii_config.mask_char.repeat(4)
432                ),
433                PIIAction::Remove => {
434                    "Remove every detected value completely and retain none of its original characters"
435                        .to_string()
436                }
437                PIIAction::Flag => {
438                    "Wrap every detected value with [PII: type]".to_string()
439                }
440            };
441            instructions.push(format!(
442                "PII rule: {}. Types: {}",
443                action,
444                pii_types.join(", ")
445            ));
446        }
447
448        if let Some(harmful_config) = &config.harmful
449            && !harmful_config.detect.is_empty()
450        {
451            let types: Vec<String> = harmful_config
452                .detect
453                .iter()
454                .map(|t| format!("{:?}", t).to_lowercase())
455                .collect();
456            instructions.push(format!("Detect harmful content: {}", types.join(", ")));
457        }
458
459        if !config.remove.is_empty() {
460            instructions.push(format!(
461                "Remove any mentions of: {}",
462                config.remove.join(", ")
463            ));
464        }
465
466        if instructions.is_empty() {
467            return Ok(data);
468        }
469
470        let prompt = format!(
471            "Sanitize the following text according to these rules:\n{}\n\n\
472             Return only the sanitized text, nothing else.\n\n\
473             Text: {}",
474            instructions.join("\n"),
475            data.content
476        );
477
478        let messages = vec![ChatMessage::user(&prompt)];
479        let response = llm
480            .complete(&messages, None)
481            .await
482            .map_err(|e| AgentError::LLM(e.to_string()))?;
483
484        data.content = response.content.trim().to_string();
485        Ok(data)
486    }
487
488    async fn execute_transform(
489        &self,
490        config: &TransformConfig,
491        mut data: ProcessData,
492    ) -> Result<ProcessData> {
493        let prompt = match &config.prompt {
494            Some(p) => p.clone(),
495            None => return Ok(data),
496        };
497
498        let llm = self.get_llm(
499            ai_agents_llm::LLMRole::ProcessTransform,
500            config.llm.as_deref(),
501        )?;
502
503        let full_prompt = format!("{}\n\nOriginal text:\n{}", prompt, data.content);
504
505        let messages = vec![ChatMessage::user(&full_prompt)];
506        let response = llm
507            .complete(&messages, None)
508            .await
509            .map_err(|e| AgentError::LLM(e.to_string()))?;
510
511        data.content = response.content.trim().to_string();
512        Ok(data)
513    }
514
515    async fn execute_validate(
516        &self,
517        config: &ValidateConfig,
518        mut data: ProcessData,
519    ) -> Result<ProcessData> {
520        // Rule-based validation
521        for rule in &config.rules {
522            match rule {
523                ValidationRule::MinLength {
524                    min_length,
525                    on_fail,
526                } => {
527                    if data.content.len() < *min_length {
528                        match on_fail.action {
529                            ValidationActionType::Reject => {
530                                data.metadata.rejected = true;
531                                data.metadata.rejection_reason = Some(format!(
532                                    "Content too short: {} < {} characters",
533                                    data.content.len(),
534                                    min_length
535                                ));
536                                return Ok(data);
537                            }
538                            ValidationActionType::Warn => {
539                                data.metadata.warnings.push(format!(
540                                    "Content shorter than {} characters",
541                                    min_length
542                                ));
543                            }
544                            ValidationActionType::Truncate => {} // N/A for min_length
545                        }
546                    }
547                }
548                ValidationRule::MaxLength {
549                    max_length,
550                    on_fail,
551                } => {
552                    if data.content.len() > *max_length {
553                        match on_fail.action {
554                            ValidationActionType::Truncate => {
555                                data.content = data.content.chars().take(*max_length).collect();
556                            }
557                            ValidationActionType::Reject => {
558                                data.metadata.rejected = true;
559                                data.metadata.rejection_reason = Some(format!(
560                                    "Content too long: {} > {} characters",
561                                    data.content.len(),
562                                    max_length
563                                ));
564                                return Ok(data);
565                            }
566                            ValidationActionType::Warn => {
567                                data.metadata
568                                    .warnings
569                                    .push(format!("Content longer than {} characters", max_length));
570                            }
571                        }
572                    }
573                }
574                ValidationRule::Pattern { pattern, on_fail } => {
575                    if let Ok(re) = regex::Regex::new(pattern)
576                        && !re.is_match(&data.content)
577                    {
578                        match on_fail.action {
579                            ValidationActionType::Reject => {
580                                data.metadata.rejected = true;
581                                data.metadata.rejection_reason =
582                                    Some("Content does not match required pattern".to_string());
583                                return Ok(data);
584                            }
585                            ValidationActionType::Warn => {
586                                data.metadata
587                                    .warnings
588                                    .push("Content does not match expected pattern".to_string());
589                            }
590                            ValidationActionType::Truncate => {} // N/A for pattern
591                        }
592                    }
593                }
594            }
595        }
596
597        // LLM-based validation
598        if !config.criteria.is_empty() {
599            let llm = self.get_llm(
600                ai_agents_llm::LLMRole::ProcessValidate,
601                config.llm.as_deref(),
602            )?;
603
604            let criteria_list = config
605                .criteria
606                .iter()
607                .enumerate()
608                .map(|(i, c)| format!("{}. {}", i + 1, c))
609                .collect::<Vec<_>>()
610                .join("\n");
611
612            let prompt = format!(
613                "Evaluate if the following content meets these criteria:\n{}\n\n\
614                 Respond with JSON: {{\"passes\": true/false, \"score\": 0.0-1.0, \"issues\": [\"...\"]}}\n\n\
615                 Content: {}",
616                criteria_list, data.content
617            );
618
619            let messages = vec![ChatMessage::user(&prompt)];
620            let response = llm
621                .complete(&messages, None)
622                .await
623                .map_err(|e| AgentError::LLM(e.to_string()))?;
624
625            if let Ok(result) =
626                serde_json::from_str::<serde_json::Value>(&extract_json(&response.content))
627            {
628                let score = result.get("score").and_then(|s| s.as_f64()).unwrap_or(1.0) as f32;
629                let passes = result
630                    .get("passes")
631                    .and_then(|p| p.as_bool())
632                    .unwrap_or(true);
633
634                if !passes || score < config.threshold {
635                    match config.on_fail.action {
636                        ValidationFailType::Reject => {
637                            data.metadata.rejected = true;
638                            let issues = result
639                                .get("issues")
640                                .and_then(|i| i.as_array())
641                                .map(|arr| {
642                                    arr.iter()
643                                        .filter_map(|v| v.as_str())
644                                        .collect::<Vec<_>>()
645                                        .join(", ")
646                                })
647                                .unwrap_or_else(|| "Validation failed".to_string());
648                            data.metadata.rejection_reason = Some(issues);
649                            return Ok(data);
650                        }
651                        ValidationFailType::Regenerate => {
652                            data.metadata
653                                .warnings
654                                .push("Content may need regeneration".to_string());
655                        }
656                        ValidationFailType::Warn => {
657                            if let Some(issues) = result.get("issues").and_then(|i| i.as_array()) {
658                                for issue in issues {
659                                    if let Some(s) = issue.as_str() {
660                                        data.metadata.warnings.push(s.to_string());
661                                    }
662                                }
663                            }
664                        }
665                    }
666                }
667            }
668        }
669
670        Ok(data)
671    }
672
673    async fn execute_format(
674        &self,
675        config: &FormatConfig,
676        mut data: ProcessData,
677    ) -> Result<ProcessData> {
678        let template = if let Some(channel) = &config.channel {
679            config
680                .channels
681                .get(channel)
682                .and_then(|c| c.template.as_ref())
683                .or(config.template.as_ref())
684        } else {
685            config.template.as_ref()
686        };
687
688        if let Some(tmpl) = template {
689            // Simple template substitution
690            let mut result = tmpl.clone();
691            result = result.replace("{{ response }}", &data.content);
692            result = result.replace("{{response}}", &data.content);
693
694            // Replace context variables
695            for (key, value) in &data.context {
696                let placeholder = format!("{{{{ context.{} }}}}", key);
697                let placeholder_no_space = format!("{{{{context.{}}}}}", key);
698                let value_str = match value {
699                    serde_json::Value::String(s) => s.clone(),
700                    _ => value.to_string(),
701                };
702                result = result.replace(&placeholder, &value_str);
703                result = result.replace(&placeholder_no_space, &value_str);
704            }
705
706            data.content = result;
707        }
708
709        // Apply channel-specific max_length
710        if let Some(channel) = &config.channel
711            && let Some(channel_config) = config.channels.get(channel)
712            && let Some(max_len) = channel_config.max_length
713            && data.content.len() > max_len
714        {
715            data.content = data.content.chars().take(max_len).collect();
716        }
717
718        Ok(data)
719    }
720
721    async fn execute_enrich(
722        &self,
723        config: &EnrichConfig,
724        mut data: ProcessData,
725    ) -> Result<ProcessData> {
726        let result = match &config.source {
727            EnrichSource::None => return Ok(data),
728            EnrichSource::Api {
729                url,
730                method: _,
731                headers: _,
732                body: _,
733                extract: _,
734            } => {
735                // API enrichment would require HTTP client
736                // For now, add a warning
737                data.metadata
738                    .warnings
739                    .push(format!("API enrichment not yet implemented: {}", url));
740                return Ok(data);
741            }
742            EnrichSource::File { path, format } => {
743                // File enrichment
744                match std::fs::read_to_string(path) {
745                    Ok(content) => match format.as_deref() {
746                        Some("json") => serde_json::from_str(&content).ok(),
747                        Some("yaml") => serde_yaml::from_str(&content).ok(),
748                        _ => Some(serde_json::Value::String(content)),
749                    },
750                    Err(e) => match config.on_error {
751                        EnrichErrorAction::Stop => return Err(AgentError::IoError(e)),
752                        EnrichErrorAction::Continue | EnrichErrorAction::Warn => {
753                            data.metadata
754                                .warnings
755                                .push(format!("File read failed: {}", e));
756                            return Ok(data);
757                        }
758                    },
759                }
760            }
761            EnrichSource::Tool { tool, args: _ } => {
762                // Tool execution would need tool registry access
763                data.metadata
764                    .warnings
765                    .push(format!("Tool enrichment not yet implemented: {}", tool));
766                return Ok(data);
767            }
768        };
769
770        if let Some(value) = result
771            && let Some(context_path) = &config.store_in_context
772        {
773            data.context.insert(context_path.clone(), value);
774        }
775
776        Ok(data)
777    }
778
779    async fn execute_conditional(
780        &self,
781        config: &ConditionalConfig,
782        data: ProcessData,
783    ) -> Result<ProcessData> {
784        let condition_met = self.evaluate_condition(&config.condition, &data);
785
786        let stages = if condition_met {
787            &config.then_stages
788        } else {
789            &config.else_stages
790        };
791
792        let mut result = data;
793        for stage in stages {
794            result = self.execute_stage(stage, result).await?;
795            if result.metadata.rejected {
796                break;
797            }
798        }
799
800        Ok(result)
801    }
802
803    fn evaluate_condition(&self, condition: &Option<ConditionExpr>, data: &ProcessData) -> bool {
804        match condition {
805            None => true,
806            Some(expr) => self.evaluate_condition_expr(expr, data),
807        }
808    }
809
810    fn evaluate_condition_expr(&self, condition: &ConditionExpr, data: &ProcessData) -> bool {
811        match condition {
812            ConditionExpr::All { all } => all.iter().all(|c| self.evaluate_condition_expr(c, data)),
813            ConditionExpr::Any { any } => any.iter().any(|c| self.evaluate_condition_expr(c, data)),
814            ConditionExpr::Simple(map) => self.evaluate_simple_condition(map, data),
815        }
816    }
817
818    fn evaluate_simple_condition(
819        &self,
820        map: &std::collections::HashMap<String, serde_json::Value>,
821        data: &ProcessData,
822    ) -> bool {
823        for (path, expected) in map {
824            let actual = self.get_nested_value(&data.context, path);
825
826            // Handle { exists: true/false }
827            if let Some(obj) = expected.as_object()
828                && let Some(exists_val) = obj.get("exists")
829            {
830                let should_exist = exists_val.as_bool().unwrap_or(true);
831                let does_exist =
832                    actual.is_some() && !matches!(actual, Some(serde_json::Value::Null));
833                if does_exist != should_exist {
834                    return false;
835                }
836                continue;
837            }
838
839            // Direct value comparison
840            match (actual, expected) {
841                (Some(a), e) if a == e => continue,
842                (None, serde_json::Value::Null) => continue,
843                _ => return false,
844            }
845        }
846        true
847    }
848
849    fn get_nested_value<'a>(
850        &self,
851        context: &'a std::collections::HashMap<String, serde_json::Value>,
852        path: &str,
853    ) -> Option<&'a serde_json::Value> {
854        let parts: Vec<&str> = path.split('.').collect();
855        if parts.is_empty() {
856            return None;
857        }
858
859        let mut current: Option<&serde_json::Value> = context.get(parts[0]);
860
861        for part in &parts[1..] {
862            current = current.and_then(|v| {
863                if let serde_json::Value::Object(obj) = v {
864                    obj.get(*part)
865                } else {
866                    None
867                }
868            });
869        }
870
871        current
872    }
873
874    // Resolve a stage role exactly before applying the owning legacy selection chain.
875    fn get_llm(
876        &self,
877        role: ai_agents_llm::LLMRole,
878        alias: Option<&str>,
879    ) -> Result<Arc<dyn LLMProvider>> {
880        let registry = self
881            .llm_registry
882            .as_ref()
883            .ok_or_else(|| AgentError::Config("LLM registry not configured for process".into()))?;
884
885        if let Some(resolved) = registry
886            .resolve_role_override(role, alias)
887            .map_err(|e| AgentError::Config(e.to_string()))?
888        {
889            return Ok(resolved.provider);
890        }
891        match alias {
892            Some(name) => registry
893                .get(name)
894                .map_err(|e| AgentError::LLM(e.to_string())),
895            None => registry
896                .router()
897                .or_else(|_| registry.default())
898                .map_err(|e| AgentError::LLM(e.to_string())),
899        }
900    }
901}
902
903fn purpose_hint_for_stages(stages: &[ProcessStage]) -> ProcessPurposeHint {
904    for stage in stages {
905        let hint = process_purpose_hint_for_stage(stage);
906        if hint != ProcessPurposeHint::Other {
907            return hint;
908        }
909    }
910    ProcessPurposeHint::Other
911}
912
913// Returns explicit natural-language labels so multiword PII types remain unambiguous to router models.
914fn pii_type_prompt_label(pii_type: &PIIType) -> &'static str {
915    match pii_type {
916        PIIType::Email => "email address",
917        PIIType::Phone => "phone number",
918        PIIType::CreditCard => "credit card number",
919        PIIType::Ssn => "social security number",
920        PIIType::IpAddress => "IP address",
921        PIIType::Name => "person name",
922        PIIType::Address => "physical address",
923    }
924}
925
926fn process_purpose_hint_for_stage(stage: &ProcessStage) -> ProcessPurposeHint {
927    match stage {
928        ProcessStage::Detect(_) => ProcessPurposeHint::Detect,
929        ProcessStage::Extract(_) => ProcessPurposeHint::Extract,
930        ProcessStage::Validate(_) => ProcessPurposeHint::Validate,
931        ProcessStage::Sanitize(_) | ProcessStage::Transform(_) => ProcessPurposeHint::Transform,
932        ProcessStage::Conditional(config) => {
933            let then_hint = purpose_hint_for_stages(&config.config.then_stages);
934            if then_hint != ProcessPurposeHint::Other {
935                then_hint
936            } else {
937                purpose_hint_for_stages(&config.config.else_stages)
938            }
939        }
940        _ => ProcessPurposeHint::Other,
941    }
942}
943
944fn extract_json(response: &str) -> String {
945    let trimmed = response.trim();
946
947    if let Some(json) = trimmed.strip_prefix("```json")
948        && let Some(end) = json.find("```")
949    {
950        return json[..end].trim().to_string();
951    }
952
953    if let Some(fenced) = trimmed.strip_prefix("```")
954        && let Some(end) = fenced.find("```")
955    {
956        return fenced[..end].trim().to_string();
957    }
958
959    if let Some(start) = trimmed.find('{')
960        && let Some(end) = trimmed.rfind('}')
961    {
962        return trimmed[start..=end].to_string();
963    }
964
965    trimmed.to_string()
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    #[test]
973    fn test_process_data_new() {
974        let data = ProcessData::new("test content");
975        assert_eq!(data.content, "test content");
976        assert_eq!(data.original, "test content");
977        assert!(data.context.is_empty());
978    }
979
980    #[test]
981    fn test_process_data_with_context() {
982        let data = ProcessData::new("test").with_context("key", serde_json::json!("value"));
983        assert!(data.context.contains_key("key"));
984    }
985
986    #[tokio::test]
987    async fn test_normalize_trim() {
988        let processor = ProcessProcessor::default();
989        let config = NormalizeConfig {
990            trim: true,
991            ..Default::default()
992        };
993        let data = ProcessData::new("  hello world  ");
994        let result = processor.execute_normalize(&config, data).await.unwrap();
995        assert_eq!(result.content, "hello world");
996    }
997
998    #[tokio::test]
999    async fn test_normalize_collapse_whitespace() {
1000        let processor = ProcessProcessor::default();
1001        let config = NormalizeConfig {
1002            trim: true,
1003            collapse_whitespace: true,
1004            ..Default::default()
1005        };
1006        let data = ProcessData::new("hello    world\n\ntest");
1007        let result = processor.execute_normalize(&config, data).await.unwrap();
1008        assert_eq!(result.content, "hello world test");
1009    }
1010
1011    #[tokio::test]
1012    async fn test_normalize_lowercase() {
1013        let processor = ProcessProcessor::default();
1014        let config = NormalizeConfig {
1015            lowercase: true,
1016            ..Default::default()
1017        };
1018        let data = ProcessData::new("Hello World");
1019        let result = processor.execute_normalize(&config, data).await.unwrap();
1020        assert_eq!(result.content, "hello world");
1021    }
1022
1023    #[tokio::test]
1024    async fn test_validate_min_length_reject() {
1025        let processor = ProcessProcessor::default();
1026        let config = ValidateConfig {
1027            rules: vec![ValidationRule::MinLength {
1028                min_length: 10,
1029                on_fail: ValidationAction {
1030                    action: ValidationActionType::Reject,
1031                    message: None,
1032                },
1033            }],
1034            ..Default::default()
1035        };
1036        let data = ProcessData::new("short");
1037        let result = processor.execute_validate(&config, data).await.unwrap();
1038        assert!(result.metadata.rejected);
1039    }
1040
1041    #[tokio::test]
1042    async fn test_validate_max_length_truncate() {
1043        let processor = ProcessProcessor::default();
1044        let config = ValidateConfig {
1045            rules: vec![ValidationRule::MaxLength {
1046                max_length: 5,
1047                on_fail: ValidationAction {
1048                    action: ValidationActionType::Truncate,
1049                    message: None,
1050                },
1051            }],
1052            ..Default::default()
1053        };
1054        let data = ProcessData::new("hello world");
1055        let result = processor.execute_validate(&config, data).await.unwrap();
1056        assert_eq!(result.content, "hello");
1057        assert!(!result.metadata.rejected);
1058    }
1059
1060    #[tokio::test]
1061    async fn test_format_simple_template() {
1062        let processor = ProcessProcessor::default();
1063        let config = FormatConfig {
1064            template: Some("Response: {{ response }}".to_string()),
1065            ..Default::default()
1066        };
1067        let data = ProcessData::new("Hello!");
1068        let result = processor.execute_format(&config, data).await.unwrap();
1069        assert_eq!(result.content, "Response: Hello!");
1070    }
1071
1072    #[test]
1073    fn test_extract_json() {
1074        assert_eq!(extract_json(r#"{"key": 1}"#), r#"{"key": 1}"#);
1075        assert_eq!(extract_json("```json\n{\"key\": 1}\n```"), r#"{"key": 1}"#);
1076        assert_eq!(extract_json("Some text {\"key\": 1} more"), r#"{"key": 1}"#);
1077    }
1078
1079    #[test]
1080    fn test_evaluate_condition_empty() {
1081        let processor = ProcessProcessor::default();
1082        let data = ProcessData::new("test");
1083        assert!(processor.evaluate_condition(&None, &data));
1084    }
1085
1086    #[test]
1087    fn test_evaluate_condition_simple_exists_true() {
1088        let processor = ProcessProcessor::default();
1089        let mut data = ProcessData::new("test");
1090        data.context.insert(
1091            "session".to_string(),
1092            serde_json::json!({ "user_name": "Alice" }),
1093        );
1094
1095        let mut map = std::collections::HashMap::new();
1096        map.insert(
1097            "session.user_name".to_string(),
1098            serde_json::json!({ "exists": true }),
1099        );
1100        let condition = ConditionExpr::Simple(map);
1101
1102        assert!(processor.evaluate_condition_expr(&condition, &data));
1103    }
1104
1105    #[test]
1106    fn test_evaluate_condition_simple_exists_false() {
1107        let processor = ProcessProcessor::default();
1108        let data = ProcessData::new("test");
1109
1110        let mut map = std::collections::HashMap::new();
1111        map.insert(
1112            "session.user_name".to_string(),
1113            serde_json::json!({ "exists": false }),
1114        );
1115        let condition = ConditionExpr::Simple(map);
1116
1117        assert!(processor.evaluate_condition_expr(&condition, &data));
1118    }
1119
1120    #[test]
1121    fn test_evaluate_condition_all() {
1122        let processor = ProcessProcessor::default();
1123        let mut data = ProcessData::new("test");
1124        data.context.insert(
1125            "session".to_string(),
1126            serde_json::json!({ "user_name": "Alice", "language": "en" }),
1127        );
1128
1129        let mut map1 = std::collections::HashMap::new();
1130        map1.insert(
1131            "session.user_name".to_string(),
1132            serde_json::json!({ "exists": true }),
1133        );
1134        let mut map2 = std::collections::HashMap::new();
1135        map2.insert(
1136            "session.language".to_string(),
1137            serde_json::json!({ "exists": true }),
1138        );
1139
1140        let condition = ConditionExpr::All {
1141            all: vec![ConditionExpr::Simple(map1), ConditionExpr::Simple(map2)],
1142        };
1143
1144        assert!(processor.evaluate_condition_expr(&condition, &data));
1145    }
1146
1147    #[test]
1148    fn test_evaluate_condition_any() {
1149        let processor = ProcessProcessor::default();
1150        let mut data = ProcessData::new("test");
1151        data.context.insert(
1152            "session".to_string(),
1153            serde_json::json!({ "tier": "premium" }),
1154        );
1155
1156        let mut map1 = std::collections::HashMap::new();
1157        map1.insert("session.tier".to_string(), serde_json::json!("premium"));
1158        let mut map2 = std::collections::HashMap::new();
1159        map2.insert("session.tier".to_string(), serde_json::json!("enterprise"));
1160
1161        let condition = ConditionExpr::Any {
1162            any: vec![ConditionExpr::Simple(map1), ConditionExpr::Simple(map2)],
1163        };
1164
1165        assert!(processor.evaluate_condition_expr(&condition, &data));
1166    }
1167
1168    #[test]
1169    fn test_evaluate_condition_value_match() {
1170        let processor = ProcessProcessor::default();
1171        let mut data = ProcessData::new("test");
1172        data.context.insert(
1173            "input".to_string(),
1174            serde_json::json!({ "sentiment": "negative" }),
1175        );
1176
1177        let mut map = std::collections::HashMap::new();
1178        map.insert("input.sentiment".to_string(), serde_json::json!("negative"));
1179        let condition = ConditionExpr::Simple(map);
1180
1181        assert!(processor.evaluate_condition_expr(&condition, &data));
1182    }
1183
1184    #[test]
1185    fn test_get_nested_value() {
1186        let processor = ProcessProcessor::default();
1187        let mut context = std::collections::HashMap::new();
1188        context.insert(
1189            "session".to_string(),
1190            serde_json::json!({ "user": { "name": "Alice" } }),
1191        );
1192
1193        let result = processor.get_nested_value(&context, "session.user.name");
1194        assert_eq!(result, Some(&serde_json::json!("Alice")));
1195
1196        let result = processor.get_nested_value(&context, "session.nonexistent");
1197        assert!(result.is_none());
1198    }
1199
1200    //
1201    // LLM-based stage tests (detect, extract, sanitize, transform, validate)
1202    //
1203    fn create_mock_registry(response: &str) -> Arc<ai_agents_llm::LLMRegistry> {
1204        use ai_agents_llm::mock::MockLLMProvider;
1205        let mut mock = MockLLMProvider::new("test");
1206        mock.set_response(response);
1207        let mut registry = ai_agents_llm::LLMRegistry::new();
1208        registry.register("default", std::sync::Arc::new(mock));
1209        registry.set_default("default");
1210        std::sync::Arc::new(registry)
1211    }
1212
1213    fn create_mock_registry_multi(responses: Vec<&str>) -> Arc<ai_agents_llm::LLMRegistry> {
1214        use ai_agents_llm::mock::MockLLMProvider;
1215        let mut mock = MockLLMProvider::new("test");
1216        mock.set_responses(responses.into_iter().map(String::from).collect(), true);
1217        let mut registry = ai_agents_llm::LLMRegistry::new();
1218        registry.register("default", std::sync::Arc::new(mock));
1219        registry.set_default("default");
1220        std::sync::Arc::new(registry)
1221    }
1222
1223    #[tokio::test]
1224    async fn test_detect_stage_language_sentiment() {
1225        let registry = create_mock_registry(
1226            r#"{"language": "ko", "sentiment": "positive", "intent": "greeting"}"#,
1227        );
1228        let config = ProcessConfig {
1229            input: vec![ProcessStage::Detect(DetectStage {
1230                id: Some("detect_test".to_string()),
1231                condition: None,
1232                config: DetectConfig {
1233                    llm: None,
1234                    detect: vec![DetectionType::Language, DetectionType::Sentiment],
1235                    intents: vec![IntentDefinition {
1236                        id: "greeting".to_string(),
1237                        description: "User says hello".to_string(),
1238                    }],
1239                    store_in_context: {
1240                        let mut m = std::collections::HashMap::new();
1241                        m.insert("language".to_string(), "input.language".to_string());
1242                        m.insert("sentiment".to_string(), "input.sentiment".to_string());
1243                        m
1244                    },
1245                },
1246            })],
1247            ..Default::default()
1248        };
1249        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1250        let result = processor.process_input("안녕하세요!").await.unwrap();
1251
1252        assert_eq!(
1253            result.context.get("input.language"),
1254            Some(&serde_json::json!("ko"))
1255        );
1256        assert_eq!(
1257            result.context.get("input.sentiment"),
1258            Some(&serde_json::json!("positive"))
1259        );
1260        assert!(
1261            result
1262                .metadata
1263                .stages_executed
1264                .contains(&"detect_test".to_string())
1265        );
1266    }
1267
1268    #[tokio::test]
1269    async fn test_extract_stage_entities() {
1270        let registry = create_mock_registry(r#"{"order_number": "ORD-12345", "urgency": "high"}"#);
1271        let config = ProcessConfig {
1272            input: vec![ProcessStage::Extract(ExtractStage {
1273                id: Some("extract_test".to_string()),
1274                condition: None,
1275                config: ExtractConfig {
1276                    llm: None,
1277                    schema: {
1278                        let mut m = std::collections::HashMap::new();
1279                        m.insert(
1280                            "order_number".to_string(),
1281                            FieldSchema {
1282                                field_type: FieldType::String,
1283                                description: Some("Order number".to_string()),
1284                                required: true,
1285                                values: vec![],
1286                            },
1287                        );
1288                        m.insert(
1289                            "urgency".to_string(),
1290                            FieldSchema {
1291                                field_type: FieldType::Enum,
1292                                description: Some("Urgency level".to_string()),
1293                                required: false,
1294                                values: vec![
1295                                    "low".to_string(),
1296                                    "medium".to_string(),
1297                                    "high".to_string(),
1298                                ],
1299                            },
1300                        );
1301                        m
1302                    },
1303                    store_in_context: Some("extracted".to_string()),
1304                },
1305            })],
1306            ..Default::default()
1307        };
1308        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1309        let result = processor
1310            .process_input("My order ORD-12345 is urgent!")
1311            .await
1312            .unwrap();
1313
1314        let extracted = result.context.get("extracted").unwrap();
1315        assert_eq!(extracted["order_number"], "ORD-12345");
1316        assert_eq!(extracted["urgency"], "high");
1317    }
1318
1319    #[test]
1320    fn test_pii_prompt_labels_are_unambiguous() {
1321        assert_eq!(
1322            pii_type_prompt_label(&PIIType::CreditCard),
1323            "credit card number"
1324        );
1325        assert_eq!(
1326            pii_type_prompt_label(&PIIType::Ssn),
1327            "social security number"
1328        );
1329        assert_eq!(pii_type_prompt_label(&PIIType::IpAddress), "IP address");
1330    }
1331
1332    #[tokio::test]
1333    async fn test_sanitize_stage_pii_masking() {
1334        let registry = create_mock_registry("Call me at ****-****-**** or email at ****@****.com");
1335        let config = ProcessConfig {
1336            input: vec![ProcessStage::Sanitize(SanitizeStage {
1337                id: Some("sanitize_test".to_string()),
1338                condition: None,
1339                config: SanitizeConfig {
1340                    llm: None,
1341                    pii: Some(PIISanitizeConfig {
1342                        action: PIIAction::Mask,
1343                        types: vec![PIIType::Phone, PIIType::Email],
1344                        mask_char: "*".to_string(),
1345                    }),
1346                    harmful: None,
1347                    remove: vec![],
1348                },
1349            })],
1350            ..Default::default()
1351        };
1352        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1353        let result = processor
1354            .process_input("Call me at 010-1234-5678 or email at user@example.com")
1355            .await
1356            .unwrap();
1357
1358        // LLM returns sanitized text
1359        assert!(result.content.contains("****"));
1360        assert!(!result.content.contains("010-1234-5678"));
1361        assert!(!result.content.contains("user@example.com"));
1362    }
1363
1364    #[tokio::test]
1365    async fn test_transform_stage_tone_adjustment() {
1366        let registry = create_mock_registry(
1367            "I understand your frustration. Let me help you resolve this issue right away.",
1368        );
1369        let config = ProcessConfig {
1370            output: vec![ProcessStage::Transform(TransformStage {
1371                id: Some("tone_test".to_string()),
1372                condition: None,
1373                config: TransformConfig {
1374                    llm: None,
1375                    prompt: Some("Rewrite to be more empathetic.".to_string()),
1376                    max_output_tokens: None,
1377                },
1378            })],
1379            ..Default::default()
1380        };
1381        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1382
1383        let input_context = std::collections::HashMap::new();
1384        let result = processor
1385            .process_output("Your request is being processed.", &input_context)
1386            .await
1387            .unwrap();
1388
1389        assert!(result.content.contains("understand"));
1390    }
1391
1392    #[tokio::test]
1393    async fn test_validate_stage_llm_criteria() {
1394        let registry = create_mock_registry(
1395            r#"{"passes": false, "score": 0.3, "issues": ["Response is too vague"]}"#,
1396        );
1397        let config = ProcessConfig {
1398            output: vec![ProcessStage::Validate(ValidateStage {
1399                id: Some("quality_test".to_string()),
1400                condition: None,
1401                config: ValidateConfig {
1402                    rules: vec![],
1403                    llm: None,
1404                    criteria: vec!["Response is specific and actionable".to_string()],
1405                    threshold: 0.7,
1406                    on_fail: ValidationFailAction {
1407                        action: ValidationFailType::Warn,
1408                        ..Default::default()
1409                    },
1410                },
1411            })],
1412            ..Default::default()
1413        };
1414        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1415
1416        let input_context = std::collections::HashMap::new();
1417        let result = processor
1418            .process_output("It depends.", &input_context)
1419            .await
1420            .unwrap();
1421
1422        // Should have a warning because score (0.3) < threshold (0.7)
1423        assert!(
1424            result.metadata.warnings.iter().any(|w| w.contains("vague")),
1425            "Expected warning about vague response, got: {:?}",
1426            result.metadata.warnings
1427        );
1428    }
1429
1430    #[tokio::test]
1431    async fn test_validate_stage_llm_criteria_reject() {
1432        let registry = create_mock_registry(
1433            r#"{"passes": false, "score": 0.2, "issues": ["Contains harmful content"]}"#,
1434        );
1435        let config = ProcessConfig {
1436            output: vec![ProcessStage::Validate(ValidateStage {
1437                id: Some("reject_test".to_string()),
1438                condition: None,
1439                config: ValidateConfig {
1440                    rules: vec![],
1441                    llm: None,
1442                    criteria: vec!["Response is safe".to_string()],
1443                    threshold: 0.7,
1444                    on_fail: ValidationFailAction {
1445                        action: ValidationFailType::Reject,
1446                        ..Default::default()
1447                    },
1448                },
1449            })],
1450            ..Default::default()
1451        };
1452        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1453
1454        let input_context = std::collections::HashMap::new();
1455        let result = processor
1456            .process_output("Dangerous content here.", &input_context)
1457            .await
1458            .unwrap();
1459
1460        assert!(result.metadata.rejected);
1461        assert!(
1462            result
1463                .metadata
1464                .rejection_reason
1465                .as_ref()
1466                .unwrap()
1467                .contains("harmful")
1468        );
1469    }
1470
1471    #[tokio::test]
1472    async fn test_full_input_pipeline_chain() {
1473        // normalize → detect → extract pipeline
1474        let registry = create_mock_registry_multi(vec![
1475            // detect response
1476            r#"{"language": "en", "sentiment": "neutral"}"#,
1477            // extract response
1478            r#"{"user_name": "Alice", "topic": "billing"}"#,
1479        ]);
1480        let config = ProcessConfig {
1481            input: vec![
1482                ProcessStage::Normalize(NormalizeStage {
1483                    id: Some("norm".to_string()),
1484                    condition: None,
1485                    config: NormalizeConfig {
1486                        trim: true,
1487                        collapse_whitespace: true,
1488                        ..Default::default()
1489                    },
1490                }),
1491                ProcessStage::Detect(DetectStage {
1492                    id: Some("detect".to_string()),
1493                    condition: None,
1494                    config: DetectConfig {
1495                        llm: None,
1496                        detect: vec![DetectionType::Language, DetectionType::Sentiment],
1497                        intents: vec![],
1498                        store_in_context: {
1499                            let mut m = std::collections::HashMap::new();
1500                            m.insert("language".to_string(), "input.language".to_string());
1501                            m
1502                        },
1503                    },
1504                }),
1505                ProcessStage::Extract(ExtractStage {
1506                    id: Some("extract".to_string()),
1507                    condition: None,
1508                    config: ExtractConfig {
1509                        llm: None,
1510                        schema: {
1511                            let mut m = std::collections::HashMap::new();
1512                            m.insert(
1513                                "user_name".to_string(),
1514                                FieldSchema {
1515                                    field_type: FieldType::String,
1516                                    description: Some("User name".to_string()),
1517                                    ..Default::default()
1518                                },
1519                            );
1520                            m
1521                        },
1522                        store_in_context: Some("entities".to_string()),
1523                    },
1524                }),
1525            ],
1526            ..Default::default()
1527        };
1528        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1529        let result = processor
1530            .process_input("  Hi, I'm   Alice and I have a billing question  ")
1531            .await
1532            .unwrap();
1533
1534        // Verify normalize ran
1535        assert_eq!(
1536            result.content,
1537            "Hi, I'm Alice and I have a billing question"
1538        );
1539
1540        // Verify detect stored context
1541        assert_eq!(
1542            result.context.get("input.language"),
1543            Some(&serde_json::json!("en"))
1544        );
1545
1546        // Verify extract stored context
1547        let entities = result.context.get("entities").unwrap();
1548        assert_eq!(entities["user_name"], "Alice");
1549
1550        // Verify all stages executed in order
1551        assert_eq!(
1552            result.metadata.stages_executed,
1553            vec!["norm", "detect", "extract"]
1554        );
1555    }
1556
1557    #[tokio::test]
1558    async fn test_conditional_stage_skips_on_false() {
1559        let registry = create_mock_registry(r#"{"language": "en"}"#);
1560        let config = ProcessConfig {
1561            input: vec![ProcessStage::Detect(DetectStage {
1562                id: Some("should_skip".to_string()),
1563                condition: Some(ConditionExpr::Simple({
1564                    let mut map = std::collections::HashMap::new();
1565                    map.insert("needs_detection".to_string(), serde_json::json!(true));
1566                    map
1567                })),
1568                config: DetectConfig {
1569                    llm: None,
1570                    detect: vec![DetectionType::Language],
1571                    ..Default::default()
1572                },
1573            })],
1574            ..Default::default()
1575        };
1576        let processor = ProcessProcessor::new(config).with_llm_registry(registry);
1577        let result = processor.process_input("Hello").await.unwrap();
1578
1579        // Stage should be skipped because "needs_detection" is not in context
1580        assert!(
1581            !result
1582                .metadata
1583                .stages_executed
1584                .contains(&"should_skip".to_string()),
1585            "Stage should have been skipped"
1586        );
1587    }
1588
1589    #[tokio::test]
1590    async fn test_stage_skipped_when_condition_false() {
1591        let config = ProcessConfig {
1592            input: vec![ProcessStage::Extract(ExtractStage {
1593                id: Some("skip_me".to_string()),
1594                condition: Some(ConditionExpr::Simple({
1595                    let mut map = std::collections::HashMap::new();
1596                    map.insert(
1597                        "session.user".to_string(),
1598                        serde_json::json!({ "exists": false }),
1599                    );
1600                    map
1601                })),
1602                config: ExtractConfig::default(),
1603            })],
1604            settings: ProcessSettings {
1605                debug: ProcessDebugConfig {
1606                    log_stages: true,
1607                    ..Default::default()
1608                },
1609                ..Default::default()
1610            },
1611            ..Default::default()
1612        };
1613        let processor = ProcessProcessor::new(config);
1614
1615        let mut data = ProcessData::new("test");
1616        data.context.insert(
1617            "session".to_string(),
1618            serde_json::json!({ "user": "Alice" }),
1619        );
1620
1621        let result = processor.process_input("test").await.unwrap();
1622        assert!(
1623            !result
1624                .metadata
1625                .stages_executed
1626                .contains(&"skip_me".to_string())
1627        );
1628    }
1629}