enact-core 0.0.2

Core agent runtime for Enact - Graph-Native AI agents
Documentation
//! Target Binding - Binds execution results to targets
//!
//! Target binding allows background executions to write their results
//! to specific targets like thread titles, execution summaries, or memory.
//!
//! @see packages/enact-schemas/src/execution.schemas.ts

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::kernel::ids::ExecutionId;
use crate::streaming::ThreadId;

/// TargetBindingType - What kind of target to bind to
/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingTypeSchema
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetBindingType {
    /// Set thread title
    #[serde(rename = "thread.title")]
    ThreadTitle,
    /// Set thread summary
    #[serde(rename = "thread.summary")]
    ThreadSummary,
    /// Set execution summary
    #[serde(rename = "execution.summary")]
    ExecutionSummary,
    /// Add to message metadata
    #[serde(rename = "message.metadata")]
    MessageMetadata,
    /// Create an artifact
    #[serde(rename = "artifact.create")]
    ArtifactCreate,
    /// Write to memory
    #[serde(rename = "memory.write")]
    MemoryWrite,
    /// Custom target (requires targetPath)
    Custom,
}

/// TargetBindingTransform - Transform to apply before binding
/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingSchema.transform
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TargetBindingTransform {
    /// No transform
    #[default]
    None,
    /// Take first line only
    FirstLine,
    /// Truncate to max length
    Truncate,
    /// Extract JSON path
    JsonExtract,
}

/// TargetBindingConfig - Configuration for binding execution result to a target
/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingSchema
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetBindingConfig {
    /// Target type
    pub target_type: TargetBindingType,

    /// Custom target path (for custom type)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_path: Option<String>,

    /// Transform to apply before binding
    #[serde(default)]
    pub transform: TargetBindingTransform,

    /// Max length for truncate transform
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_length: Option<usize>,

    /// JSON path for json_extract transform
    #[serde(skip_serializing_if = "Option::is_none")]
    pub json_path: Option<String>,

    /// Thread ID to bind to (for thread.* targets)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thread_id: Option<ThreadId>,

    /// Execution ID to bind to (for execution.* targets)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub execution_id: Option<ExecutionId>,
}

impl TargetBindingConfig {
    /// Create a thread title binding
    pub fn thread_title(thread_id: ThreadId) -> Self {
        Self {
            target_type: TargetBindingType::ThreadTitle,
            target_path: None,
            transform: TargetBindingTransform::FirstLine,
            max_length: Some(100),
            json_path: None,
            thread_id: Some(thread_id),
            execution_id: None,
        }
    }

    /// Create a thread summary binding
    pub fn thread_summary(thread_id: ThreadId) -> Self {
        Self {
            target_type: TargetBindingType::ThreadSummary,
            target_path: None,
            transform: TargetBindingTransform::Truncate,
            max_length: Some(500),
            json_path: None,
            thread_id: Some(thread_id),
            execution_id: None,
        }
    }

    /// Create an execution summary binding
    pub fn execution_summary(execution_id: ExecutionId) -> Self {
        Self {
            target_type: TargetBindingType::ExecutionSummary,
            target_path: None,
            transform: TargetBindingTransform::Truncate,
            max_length: Some(500),
            json_path: None,
            thread_id: None,
            execution_id: Some(execution_id),
        }
    }

    /// Create a memory write binding
    pub fn memory_write(memory_key: String) -> Self {
        Self {
            target_type: TargetBindingType::MemoryWrite,
            target_path: Some(memory_key),
            transform: TargetBindingTransform::None,
            max_length: None,
            json_path: None,
            thread_id: None,
            execution_id: None,
        }
    }

    /// Create an artifact binding
    pub fn artifact(artifact_type: String) -> Self {
        Self {
            target_type: TargetBindingType::ArtifactCreate,
            target_path: Some(artifact_type),
            transform: TargetBindingTransform::None,
            max_length: None,
            json_path: None,
            thread_id: None,
            execution_id: None,
        }
    }

    /// Create a custom binding
    pub fn custom(path: String) -> Self {
        Self {
            target_type: TargetBindingType::Custom,
            target_path: Some(path),
            transform: TargetBindingTransform::None,
            max_length: None,
            json_path: None,
            thread_id: None,
            execution_id: None,
        }
    }
}

/// TargetBindingResult - Result of applying a target binding
/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingResultSchema
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetBindingResult {
    /// Target type
    pub target_type: TargetBindingType,

    /// Target path (for custom targets)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_path: Option<String>,

    /// Whether binding succeeded
    pub success: bool,

    /// Value that was applied
    #[serde(skip_serializing_if = "Option::is_none")]
    pub applied_value: Option<serde_json::Value>,

    /// Error message if failed
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,

    /// When binding was applied
    pub applied_at: DateTime<Utc>,
}

impl TargetBindingResult {
    /// Create a success result
    pub fn success(config: &TargetBindingConfig, applied_value: serde_json::Value) -> Self {
        Self {
            target_type: config.target_type.clone(),
            target_path: config.target_path.clone(),
            success: true,
            applied_value: Some(applied_value),
            error: None,
            applied_at: Utc::now(),
        }
    }

    /// Create a failure result
    pub fn failure(config: &TargetBindingConfig, error: impl Into<String>) -> Self {
        Self {
            target_type: config.target_type.clone(),
            target_path: config.target_path.clone(),
            success: false,
            applied_value: None,
            error: Some(error.into()),
            applied_at: Utc::now(),
        }
    }
}

/// Apply a transform to a value
pub fn apply_transform(
    value: &str,
    transform: &TargetBindingTransform,
    max_length: Option<usize>,
    json_path: Option<&str>,
) -> Result<String, String> {
    match transform {
        TargetBindingTransform::None => Ok(value.to_string()),
        TargetBindingTransform::FirstLine => {
            let first_line = value.lines().next().unwrap_or("").trim();
            let result = if let Some(max) = max_length {
                truncate_string(first_line, max)
            } else {
                first_line.to_string()
            };
            Ok(result)
        }
        TargetBindingTransform::Truncate => {
            let max = max_length.unwrap_or(500);
            Ok(truncate_string(value, max))
        }
        TargetBindingTransform::JsonExtract => {
            let path = json_path.ok_or("json_path required for JsonExtract transform")?;
            extract_json_path(value, path)
        }
    }
}

/// Truncate a string to a maximum length, adding ellipsis if needed
fn truncate_string(s: &str, max_len: usize) -> String {
    if s.len() <= max_len {
        s.to_string()
    } else if max_len <= 3 {
        s.chars().take(max_len).collect()
    } else {
        let truncated: String = s.chars().take(max_len - 3).collect();
        format!("{}...", truncated)
    }
}

/// Extract a value from JSON using a simple path
/// Supports: $.field, $.field.subfield, $.array[0], $.array[*].field
fn extract_json_path(json_str: &str, path: &str) -> Result<String, String> {
    let value: serde_json::Value =
        serde_json::from_str(json_str).map_err(|e| format!("Invalid JSON: {}", e))?;

    // Simple path extraction (not full JSONPath)
    let path = path.trim_start_matches('$').trim_start_matches('.');
    let parts: Vec<&str> = path.split('.').collect();

    let mut current = &value;
    for part in parts {
        if part.is_empty() {
            continue;
        }

        // Check for array index
        if let Some(idx_start) = part.find('[') {
            let field = &part[..idx_start];
            if !field.is_empty() {
                current = current
                    .get(field)
                    .ok_or_else(|| format!("Field '{}' not found", field))?;
            }

            let idx_end = part.find(']').ok_or("Malformed array index")?;
            let idx_str = &part[idx_start + 1..idx_end];

            if idx_str == "*" {
                // Return all array elements
                if let Some(arr) = current.as_array() {
                    let results: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
                    return Ok(results.join(", "));
                } else {
                    return Err("Expected array for [*] index".to_string());
                }
            } else {
                let idx: usize = idx_str
                    .parse()
                    .map_err(|_| format!("Invalid array index: {}", idx_str))?;
                current = current
                    .get(idx)
                    .ok_or_else(|| format!("Array index {} out of bounds", idx))?;
            }
        } else {
            current = current
                .get(part)
                .ok_or_else(|| format!("Field '{}' not found", part))?;
        }
    }

    // Return the extracted value
    match current {
        serde_json::Value::String(s) => Ok(s.clone()),
        v => Ok(v.to_string()),
    }
}

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

    #[test]
    fn test_target_binding_types() {
        // Test thread title
        let thread_id = ThreadId::new();
        let config = TargetBindingConfig::thread_title(thread_id.clone());
        assert_eq!(config.target_type, TargetBindingType::ThreadTitle);
        assert_eq!(config.thread_id, Some(thread_id));
        assert_eq!(config.transform, TargetBindingTransform::FirstLine);
    }

    #[test]
    fn test_transform_first_line() {
        let value = "First line\nSecond line\nThird line";
        let result = apply_transform(value, &TargetBindingTransform::FirstLine, None, None);
        assert_eq!(result.unwrap(), "First line");
    }

    #[test]
    fn test_transform_truncate() {
        let value = "This is a long string that needs to be truncated";
        let result = apply_transform(value, &TargetBindingTransform::Truncate, Some(20), None);
        assert_eq!(result.unwrap(), "This is a long st...");
    }

    #[test]
    fn test_transform_json_extract() {
        let json = r#"{"name": "test", "nested": {"value": 42}}"#;

        // Extract top-level field
        let result = apply_transform(
            json,
            &TargetBindingTransform::JsonExtract,
            None,
            Some("$.name"),
        );
        assert_eq!(result.unwrap(), "test");

        // Extract nested field
        let result = apply_transform(
            json,
            &TargetBindingTransform::JsonExtract,
            None,
            Some("$.nested.value"),
        );
        assert_eq!(result.unwrap(), "42");
    }

    #[test]
    fn test_target_binding_result() {
        let config = TargetBindingConfig::thread_title(ThreadId::new());

        // Success
        let result = TargetBindingResult::success(&config, serde_json::json!("New Title"));
        assert!(result.success);
        assert_eq!(result.applied_value, Some(serde_json::json!("New Title")));

        // Failure
        let result = TargetBindingResult::failure(&config, "Thread not found");
        assert!(!result.success);
        assert_eq!(result.error, Some("Thread not found".to_string()));
    }

    #[test]
    fn test_truncate_string() {
        assert_eq!(truncate_string("short", 10), "short");
        assert_eq!(truncate_string("exactly10c", 10), "exactly10c");
        assert_eq!(truncate_string("this is too long", 10), "this is...");
        assert_eq!(truncate_string("ab", 2), "ab");
        assert_eq!(truncate_string("abc", 3), "abc");
    }
}