potato-spec 0.26.0

YAML spec loader for potato-head agents and workflows
Documentation
use serde::Deserialize;

#[derive(Debug, Clone)]
pub enum PromptRef {
    Inline(String),
    File(String),
}

impl<'de> Deserialize<'de> for PromptRef {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        use serde::de::{self, Visitor};
        struct V;
        impl<'de> Visitor<'de> for V {
            type Value = PromptRef;
            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "a string or a map with a 'path' key")
            }
            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
                Ok(PromptRef::Inline(v.to_owned()))
            }
            fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
                let mut path: Option<String> = None;
                while let Some(key) = map.next_key::<String>()? {
                    if key == "path" {
                        path = Some(map.next_value()?);
                    } else {
                        map.next_value::<serde::de::IgnoredAny>()?;
                    }
                }
                path.map(PromptRef::File)
                    .ok_or_else(|| de::Error::missing_field("path"))
            }
        }
        d.deserialize_any(V)
    }
}

#[derive(Debug, Deserialize)]
pub struct PotatoSpec {
    #[serde(default)]
    pub agents: Vec<AgentSpec>,
    #[serde(default)]
    pub workflows: Vec<WorkflowSpec>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct AgentSpec {
    pub id: String,
    #[serde(default)]
    pub provider: Option<String>,
    pub model: Option<String>,
    pub system_prompt: Option<String>,
    pub max_iterations: Option<u32>,
    pub memory: Option<MemorySpec>,
    #[serde(default)]
    pub criteria: Vec<CriteriaSpec>,
    #[serde(default)]
    pub callbacks: Vec<CallbackSpec>,
    #[serde(default)]
    pub tools: Vec<ToolRef>,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MemorySpec {
    InMemory,
    Windowed { window_size: usize },
}

#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CriteriaSpec {
    MaxIterations { max: u32 },
    Keyword { keyword: String },
    StructuredOutput { schema: Option<serde_json::Value> },
}

#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum CallbackSpec {
    BuiltIn {
        #[serde(rename = "type")]
        kind: String,
    },
    Named {
        name: String,
    },
}

#[derive(Debug, Deserialize, Clone)]
pub struct ToolRef {
    pub name: String,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WorkflowSpec {
    Sequential {
        id: String,
        pass_output: Option<bool>,
        steps: Vec<StepSpec>,
    },
    Parallel {
        id: String,
        merge_strategy: Option<MergeStrategySpec>,
        steps: Vec<StepSpec>,
    },
    Workflow {
        id: String,
        tasks: Vec<TaskSpec>,
    },
}

impl WorkflowSpec {
    pub fn id(&self) -> &str {
        match self {
            Self::Sequential { id, .. } => id,
            Self::Parallel { id, .. } => id,
            Self::Workflow { id, .. } => id,
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum StepSpec {
    Ref {
        #[serde(rename = "ref")]
        agent_ref: String,
    },
    Inline(AgentSpec),
}

#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum MergeStrategySpec {
    #[default]
    CollectAll,
    First,
}

#[derive(Debug, Deserialize, Clone)]
pub struct TaskSpec {
    pub id: String,
    pub agent: String,
    pub prompt: PromptRef,
    #[serde(default)]
    pub dependencies: Vec<String>,
    pub max_retries: Option<u32>,
}

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

    #[test]
    fn prompt_ref_deserializes_from_string() {
        let yaml = "\"hello world\"";
        let p: PromptRef = serde_yaml::from_str(yaml).unwrap();
        assert!(matches!(p, PromptRef::Inline(s) if s == "hello world"));
    }

    #[test]
    fn prompt_ref_deserializes_from_path_map() {
        let yaml = "path: ./prompts/foo.yaml";
        let p: PromptRef = serde_yaml::from_str(yaml).unwrap();
        assert!(matches!(p, PromptRef::File(s) if s == "./prompts/foo.yaml"));
    }

    #[test]
    fn prompt_ref_map_missing_path_returns_error() {
        let yaml = "other_key: value";
        let result: Result<PromptRef, _> = serde_yaml::from_str(yaml);
        assert!(result.is_err());
    }
}