enact-core 0.0.2

Core agent runtime for Enact - Graph-Native AI agents
Documentation
//! Execution Policy - Limits and constraints on execution

use super::{PolicyContext, PolicyDecision, PolicyEvaluator};
use serde::{Deserialize, Serialize};
use std::time::Duration;

/// Execution limits
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionLimits {
    /// Maximum execution time
    pub max_duration: Option<Duration>,
    /// Maximum number of steps
    pub max_steps: Option<usize>,
    /// Maximum number of LLM calls
    pub max_llm_calls: Option<usize>,
    /// Maximum number of tool invocations
    pub max_tool_invocations: Option<usize>,
    /// Maximum input size in bytes
    pub max_input_size: Option<usize>,
    /// Maximum output size in bytes
    pub max_output_size: Option<usize>,
    /// Maximum concurrent branches (for parallel execution)
    pub max_concurrent_branches: Option<usize>,
}

impl Default for ExecutionLimits {
    fn default() -> Self {
        Self {
            max_duration: Some(Duration::from_secs(300)), // 5 minutes
            max_steps: Some(100),
            max_llm_calls: Some(50),
            max_tool_invocations: Some(100),
            max_input_size: Some(1024 * 1024),       // 1MB
            max_output_size: Some(10 * 1024 * 1024), // 10MB
            max_concurrent_branches: Some(10),
        }
    }
}

impl ExecutionLimits {
    /// Create unlimited limits (for testing/development)
    pub fn unlimited() -> Self {
        Self {
            max_duration: None,
            max_steps: None,
            max_llm_calls: None,
            max_tool_invocations: None,
            max_input_size: None,
            max_output_size: None,
            max_concurrent_branches: None,
        }
    }

    /// Create strict limits (for production)
    pub fn strict() -> Self {
        Self {
            max_duration: Some(Duration::from_secs(60)), // 1 minute
            max_steps: Some(20),
            max_llm_calls: Some(10),
            max_tool_invocations: Some(20),
            max_input_size: Some(64 * 1024),    // 64KB
            max_output_size: Some(1024 * 1024), // 1MB
            max_concurrent_branches: Some(3),
        }
    }
}

/// Execution policy
#[derive(Debug, Clone)]
pub struct ExecutionPolicy {
    /// Execution limits
    pub limits: ExecutionLimits,
    /// Allow nested executions
    pub allow_nested: bool,
    /// Allow parallel execution
    pub allow_parallel: bool,
    /// Require approval for certain actions
    pub require_approval: Vec<String>,
}

impl Default for ExecutionPolicy {
    fn default() -> Self {
        Self {
            limits: ExecutionLimits::default(),
            allow_nested: true,
            allow_parallel: true,
            require_approval: Vec::new(),
        }
    }
}

impl ExecutionPolicy {
    /// Create a new execution policy
    pub fn new() -> Self {
        Self::default()
    }

    /// Set limits
    pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
        self.limits = limits;
        self
    }

    /// Disallow nested executions
    pub fn no_nested(mut self) -> Self {
        self.allow_nested = false;
        self
    }

    /// Disallow parallel execution
    pub fn no_parallel(mut self) -> Self {
        self.allow_parallel = false;
        self
    }

    /// Require approval for specific action types
    pub fn require_approval_for(mut self, action: impl Into<String>) -> Self {
        self.require_approval.push(action.into());
        self
    }
}

impl PolicyEvaluator for ExecutionPolicy {
    fn evaluate(&self, context: &PolicyContext) -> PolicyDecision {
        match &context.action {
            super::PolicyAction::StartExecution { .. } => {
                // Check if nested execution is allowed
                if !self.allow_nested && context.metadata.contains_key("parent_execution_id") {
                    return PolicyDecision::Deny {
                        reason: "Nested executions are not allowed".to_string(),
                    };
                }
                PolicyDecision::Allow
            }
            _ => PolicyDecision::Allow,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    // ============ ExecutionLimits Tests ============

    #[test]
    fn test_execution_limits_default() {
        let limits = ExecutionLimits::default();
        assert_eq!(limits.max_duration, Some(Duration::from_secs(300)));
        assert_eq!(limits.max_steps, Some(100));
        assert_eq!(limits.max_llm_calls, Some(50));
        assert_eq!(limits.max_tool_invocations, Some(100));
        assert_eq!(limits.max_input_size, Some(1024 * 1024));
        assert_eq!(limits.max_output_size, Some(10 * 1024 * 1024));
        assert_eq!(limits.max_concurrent_branches, Some(10));
    }

    #[test]
    fn test_execution_limits_unlimited() {
        let limits = ExecutionLimits::unlimited();
        assert!(limits.max_duration.is_none());
        assert!(limits.max_steps.is_none());
        assert!(limits.max_llm_calls.is_none());
        assert!(limits.max_tool_invocations.is_none());
        assert!(limits.max_input_size.is_none());
        assert!(limits.max_output_size.is_none());
        assert!(limits.max_concurrent_branches.is_none());
    }

    #[test]
    fn test_execution_limits_strict() {
        let limits = ExecutionLimits::strict();
        assert_eq!(limits.max_duration, Some(Duration::from_secs(60)));
        assert_eq!(limits.max_steps, Some(20));
        assert_eq!(limits.max_llm_calls, Some(10));
        assert_eq!(limits.max_tool_invocations, Some(20));
        assert_eq!(limits.max_input_size, Some(64 * 1024));
        assert_eq!(limits.max_output_size, Some(1024 * 1024));
        assert_eq!(limits.max_concurrent_branches, Some(3));
    }

    // ============ ExecutionPolicy Tests ============

    #[test]
    fn test_execution_policy_default() {
        let policy = ExecutionPolicy::default();
        assert!(policy.allow_nested);
        assert!(policy.allow_parallel);
        assert!(policy.require_approval.is_empty());
    }

    #[test]
    fn test_execution_policy_new() {
        let policy = ExecutionPolicy::new();
        assert!(policy.allow_nested);
        assert!(policy.allow_parallel);
    }

    #[test]
    fn test_execution_policy_with_limits() {
        let policy = ExecutionPolicy::new().with_limits(ExecutionLimits::strict());
        assert_eq!(policy.limits.max_steps, Some(20));
    }

    #[test]
    fn test_execution_policy_no_nested() {
        let policy = ExecutionPolicy::new().no_nested();
        assert!(!policy.allow_nested);
    }

    #[test]
    fn test_execution_policy_no_parallel() {
        let policy = ExecutionPolicy::new().no_parallel();
        assert!(!policy.allow_parallel);
    }

    #[test]
    fn test_execution_policy_require_approval() {
        let policy = ExecutionPolicy::new()
            .require_approval_for("external_api")
            .require_approval_for("filesystem_write");

        assert!(policy
            .require_approval
            .contains(&"external_api".to_string()));
        assert!(policy
            .require_approval
            .contains(&"filesystem_write".to_string()));
    }

    // ============ PolicyEvaluator Tests ============

    #[test]
    fn test_execution_policy_evaluate_start_allowed() {
        let policy = ExecutionPolicy::new();
        let context = PolicyContext {
            tenant_id: None,
            user_id: None,
            action: super::super::PolicyAction::StartExecution {
                graph_id: Some("graph-1".to_string()),
            },
            metadata: HashMap::new(),
        };

        let decision = policy.evaluate(&context);
        assert!(decision.is_allowed());
    }

    #[test]
    fn test_execution_policy_evaluate_nested_allowed() {
        let policy = ExecutionPolicy::new(); // allow_nested = true by default
        let mut metadata = HashMap::new();
        metadata.insert("parent_execution_id".to_string(), "exec-123".to_string());

        let context = PolicyContext {
            tenant_id: None,
            user_id: None,
            action: super::super::PolicyAction::StartExecution { graph_id: None },
            metadata,
        };

        let decision = policy.evaluate(&context);
        assert!(decision.is_allowed());
    }

    #[test]
    fn test_execution_policy_evaluate_nested_denied() {
        let policy = ExecutionPolicy::new().no_nested();
        let mut metadata = HashMap::new();
        metadata.insert("parent_execution_id".to_string(), "exec-123".to_string());

        let context = PolicyContext {
            tenant_id: None,
            user_id: None,
            action: super::super::PolicyAction::StartExecution { graph_id: None },
            metadata,
        };

        let decision = policy.evaluate(&context);
        assert!(decision.is_denied());
    }

    #[test]
    fn test_execution_policy_evaluate_other_actions_allowed() {
        let policy = ExecutionPolicy::new().no_nested().no_parallel();
        let context = PolicyContext {
            tenant_id: None,
            user_id: None,
            action: super::super::PolicyAction::LlmCall {
                model: "gpt-4".to_string(),
            },
            metadata: HashMap::new(),
        };

        // Non-execution actions are always allowed by ExecutionPolicy
        let decision = policy.evaluate(&context);
        assert!(decision.is_allowed());
    }
}