tirea-contract 0.5.0

Agent runtime contracts: 8-phase plugin lifecycle, typed tool traits, and state scope system
Documentation
use crate::storage::RunOrigin;

/// Strongly typed scope and execution policy carried with a resolved run.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunPolicy {
    allowed_tools: Option<Vec<String>>,
    excluded_tools: Option<Vec<String>>,
    allowed_skills: Option<Vec<String>>,
    excluded_skills: Option<Vec<String>>,
    allowed_agents: Option<Vec<String>>,
    excluded_agents: Option<Vec<String>>,
}

impl RunPolicy {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    pub fn allowed_tools(&self) -> Option<&[String]> {
        self.allowed_tools.as_deref()
    }

    pub fn excluded_tools(&self) -> Option<&[String]> {
        self.excluded_tools.as_deref()
    }

    pub fn allowed_skills(&self) -> Option<&[String]> {
        self.allowed_skills.as_deref()
    }

    pub fn excluded_skills(&self) -> Option<&[String]> {
        self.excluded_skills.as_deref()
    }

    pub fn allowed_agents(&self) -> Option<&[String]> {
        self.allowed_agents.as_deref()
    }

    pub fn excluded_agents(&self) -> Option<&[String]> {
        self.excluded_agents.as_deref()
    }

    pub fn set_allowed_tools_if_absent(&mut self, values: Option<&[String]>) {
        if self.allowed_tools.is_none() {
            self.allowed_tools = normalize_scope_values(values);
        }
    }

    pub fn set_excluded_tools_if_absent(&mut self, values: Option<&[String]>) {
        if self.excluded_tools.is_none() {
            self.excluded_tools = normalize_scope_values(values);
        }
    }

    pub fn set_allowed_skills_if_absent(&mut self, values: Option<&[String]>) {
        if self.allowed_skills.is_none() {
            self.allowed_skills = normalize_scope_values(values);
        }
    }

    pub fn set_excluded_skills_if_absent(&mut self, values: Option<&[String]>) {
        if self.excluded_skills.is_none() {
            self.excluded_skills = normalize_scope_values(values);
        }
    }

    pub fn set_allowed_agents_if_absent(&mut self, values: Option<&[String]>) {
        if self.allowed_agents.is_none() {
            self.allowed_agents = normalize_scope_values(values);
        }
    }

    pub fn set_excluded_agents_if_absent(&mut self, values: Option<&[String]>) {
        if self.excluded_agents.is_none() {
            self.excluded_agents = normalize_scope_values(values);
        }
    }
}

fn normalize_scope_values(values: Option<&[String]>) -> Option<Vec<String>> {
    let parsed: Vec<String> = values
        .into_iter()
        .flatten()
        .map(|value| value.trim())
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .collect();
    if parsed.is_empty() {
        None
    } else {
        Some(parsed)
    }
}

/// Strongly typed identity for the active run.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunIdentity {
    pub thread_id: String,
    pub parent_thread_id: Option<String>,
    pub run_id: String,
    pub parent_run_id: Option<String>,
    pub agent_id: String,
    pub origin: RunOrigin,
    pub parent_tool_call_id: Option<String>,
}

impl RunIdentity {
    #[must_use]
    pub fn for_thread(thread_id: impl Into<String>) -> Self {
        Self {
            thread_id: thread_id.into(),
            ..Self::default()
        }
    }

    #[must_use]
    pub fn new(
        thread_id: String,
        parent_thread_id: Option<String>,
        run_id: String,
        parent_run_id: Option<String>,
        agent_id: String,
        origin: RunOrigin,
    ) -> Self {
        Self {
            thread_id,
            parent_thread_id,
            run_id,
            parent_run_id,
            agent_id,
            origin,
            parent_tool_call_id: None,
        }
    }

    #[must_use]
    pub fn with_parent_tool_call_id(mut self, parent_tool_call_id: impl Into<String>) -> Self {
        let value = parent_tool_call_id.into();
        if !value.trim().is_empty() {
            self.parent_tool_call_id = Some(value);
        }
        self
    }

    pub fn thread_id_opt(&self) -> Option<&str> {
        let thread_id = self.thread_id.trim();
        if thread_id.is_empty() {
            None
        } else {
            Some(thread_id)
        }
    }

    pub fn parent_thread_id_opt(&self) -> Option<&str> {
        self.parent_thread_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
    }

    pub fn run_id_opt(&self) -> Option<&str> {
        let run_id = self.run_id.trim();
        if run_id.is_empty() {
            None
        } else {
            Some(run_id)
        }
    }

    pub fn parent_run_id_opt(&self) -> Option<&str> {
        self.parent_run_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
    }

    pub fn agent_id_opt(&self) -> Option<&str> {
        let agent_id = self.agent_id.trim();
        if agent_id.is_empty() {
            None
        } else {
            Some(agent_id)
        }
    }

    pub fn parent_tool_call_id_opt(&self) -> Option<&str> {
        self.parent_tool_call_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
    }
}

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

    #[test]
    fn run_policy_normalizes_values() {
        let mut policy = RunPolicy::new();
        policy.set_allowed_tools_if_absent(Some(&[" a ".to_string(), "".to_string()]));
        assert_eq!(policy.allowed_tools(), Some(&["a".to_string()][..]));
    }

    #[test]
    fn run_identity_ignores_blank_parent_tool_call_id() {
        let identity = RunIdentity::new(
            "thread-1".to_string(),
            None,
            "run-1".to_string(),
            None,
            "agent-1".to_string(),
            RunOrigin::Internal,
        )
        .with_parent_tool_call_id("   ");
        assert!(identity.parent_tool_call_id_opt().is_none());
    }
}